Status: ready-for-dev
作为测试开发者, 我想要文件上传工具有充分的单元测试, 以便确保函数的正确性和稳定性。
Given 文件上传工具函数已实现(packages/e2e-test-utils/src/file-upload.ts)
When 创建 tests/unit/file-upload.test.ts
Then 验收标准如下:
测试覆盖率 ≥ 80%(NFR29)
测试用例包括:成功上传、文件不存在、选择器无效、超时
使用 Vitest 运行测试
vitest 作为测试框架tests/unit/file-upload.test.ts所有测试通过
[ ] Task 1: 创建测试文件目录结构 (AC: #3)
tests/unit/ 目录(如果不存在)tests/fixtures/ 测试资源目录(如果不存在)[ ] Task 2: 实现文件上传成功场景测试 (AC: #2)
images/sample.jpg)setInputFiles API 被正确调用[ ] Task 3: 实现错误场景测试 (AC: #2)
E2ETestError)E2ETestError)[ ] Task 4: 实现边界条件和安全测试 (AC: #2)
../ 路径被拒绝)[ ] Task 5: 创建测试 fixtures 文件 (AC: #1, #2)
tests/fixtures/images/test-sample.jpg 占位文件tests/fixtures/documents/test-sample.pdf 占位文件[ ] Task 6: 运行测试并验证覆盖率 (AC: #1, #4)
pnpm test --testNamePattern "file-upload" 验证测试通过pnpm test:coverage 验证覆盖率 ≥ 80%[ ] Task 7: 添加测试文档和注释 (NFR25-NFR40)
Epic 3: 文件上传工具开发与验证
遵循 Epic 2 的成功模式,开发文件上传工具并在真实 E2E 测试中验证,解决当前测试超时阻塞问题。
模式: 工具开发 → 真实 E2E 测试验证 → 问题修复 → 稳定性验证
当前进度:
uploadFileToField() 函数已实现单元测试的局限性(来自 Epic 2 回顾):
单元测试无法发现真实 DOM 问题
真实 E2E 测试不可替代
本 Story 的定位
resolveFixturePath() 函数逻辑、错误抛出、安全防护uploadFileToField() 函数结构:
// packages/e2e-test-utils/src/file-upload.ts
export async function uploadFileToField(
page: Page,
selector: string,
fileName: string,
options?: FileUploadOptions
): Promise<void>
// 内部辅助函数(需要重点测试)
function resolveFixturePath(fileName: string, fixturesDir: string): string
关键测试点:
resolveFixturePath() 函数逻辑 - 核心测试重点
.. 的路径错误处理
E2ETestErrorE2ETestErrorPlaywright API 交互 - 单元测试中的模拟
page.locator(selector) 返回 Locator 对象locator.setInputFiles(filePath, { timeout }) 被调用page.waitForTimeout(200) 在 waitForUpload: true 时被调用配置文件: packages/e2e-test-utils/vitest.config.ts
{
test: {
dir: './tests/unit', // 单元测试目录
environment: 'node', // Node.js 环境
testTimeout: 10000, // 10秒超时
coverage: {
statements: 80, // 语句覆盖率目标
branches: 80, // 分支覆盖率目标
functions: 80, // 函数覆盖率目标
lines: 80 // 行覆盖率目标
}
}
}
Mock Playwright Page 对象:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { uploadFileToField } from '../src/file-upload';
import { E2ETestError } from '../src/errors';
// Mock Page 和 Locator
const mockLocator = {
setInputFiles: vi.fn().mockResolvedValue(undefined)
};
const mockPage = {
locator: vi.fn().mockReturnValue(mockLocator),
waitForTimeout: vi.fn().mockResolvedValue(undefined)
};
测试目标: 验证基本文件上传流程
it('应该成功上传文件(使用默认 fixtures 目录)', async () => {
// Arrange
const fileName = 'test-sample.jpg';
const selector = 'photo-upload';
// Act
await uploadFileToField(mockPage, selector, fileName);
// Assert
expect(mockPage.locator).toHaveBeenCalledWith(selector);
expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
expect.stringContaining(fileName),
{ timeout: 5000 }
);
});
测试目标: 验证自定义 fixtures 目录配置
it('应该使用自定义 fixtures 目录上传文件', async () => {
// Arrange
const customDir = 'custom/fixtures';
const fileName = 'test-sample.jpg';
// Act
await uploadFileToField(mockPage, 'photo-upload', fileName, {
fixturesDir: customDir
});
// Assert
expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
expect.stringContaining(customDir),
expect.anything()
);
});
测试目标: 验证文件不存在时抛出正确的错误
it('应该在文件不存在时抛出 E2ETestError', async () => {
// Arrange
const nonExistentFile = 'non-existent-file.jpg';
// Act & Assert
await expect(
uploadFileToField(mockPage, 'photo-upload', nonExistentFile)
).rejects.toThrow(E2ETestError);
});
测试目标: 验证选择器无效时抛出正确的错误
it('应该在选择器无效时抛出 E2ETestError', async () => {
// Arrange
mockLocator.setInputFiles.mockRejectedValue(
new Error('Element not found')
);
// Act & Assert
await expect(
uploadFileToField(mockPage, 'invalid-selector', 'test.jpg')
).rejects.toThrow(E2ETestError);
});
测试目标: 验证安全防护机制
it('应该拒绝包含 ".." 的路径(路径遍历攻击防护)', async () => {
// Act & Assert
await expect(
uploadFileToField(mockPage, 'photo-upload', '../../../etc/passwd')
).rejects.toThrow(E2ETestError);
});
it('应该拒绝绝对路径', async () => {
// Act & Assert
await expect(
uploadFileToField(mockPage, 'photo-upload', '/etc/passwd')
).rejects.toThrow(E2ETestError);
});
测试目标: 验证自定义超时生效
it('应该使用自定义超时配置', async () => {
// Arrange
const customTimeout = 10000;
// Act
await uploadFileToField(mockPage, 'photo-upload', 'test.jpg', {
timeout: customTimeout
});
// Assert
expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
expect.anything(),
{ timeout: customTimeout }
);
});
包结构:
packages/e2e-test-utils/
├── src/
│ ├── file-upload.ts # 被测函数
│ ├── types.ts # FileUploadOptions 类型
│ ├── errors.ts # E2ETestError 类
│ └── constants.ts # DEFAULT_TIMEOUTS 常量
├── tests/
│ ├── unit/
│ │ └── file-upload.test.ts # 本 Story 创建的测试文件
│ └── fixtures/ # 测试资源目录
│ ├── images/
│ │ └── test-sample.jpg
│ └── documents/
│ └── test-sample.pdf
├── vitest.config.ts # Vitest 配置
└── package.json
测试 fixtures 目录位置:
| 目录 | 用途 |
|---|---|
packages/e2e-test-utils/tests/fixtures/ |
单元测试使用的 fixtures |
web/tests/fixtures/ |
默认配置的 fixtures(用于 E2E 测试) |
运行单元测试:
# 在 packages/e2e-test-utils 目录下
pnpm test --testNamePattern "file-upload"
# 从项目根目录
pnpm --filter @d8d/e2e-test-utils test --testNamePattern "file-upload"
运行覆盖率测试:
pnpm test:coverage
架构文档:
_bmad-output/planning-artifacts/architecture.md - 测试策略、单元测试规范E2E 测试标准:
docs/standards/testing-standards.md - 单元测试编写规范Epic 3 完整需求:
_bmad-output/planning-artifacts/epics.md - Epic 3 和 Story 3.2 详细需求Epic 2 回顾(关键经验):
_bmad-output/implementation-artifacts/epic-2-retrospective.md - 单元测试局限性TypeScript + Playwright 陷阱:
architecture.md 第 533-657 行 - DOM 结构假设必须验证被测文件:
相关类型:
错误处理:
常量定义:
Monorepo 结构对齐:
packages/e2e-test-utils/文件组织:
tests/unit/ 目录tests/fixtures/ 目录.test.ts 后缀命名源文档引用:
Epic 2 经验教训:
Claude Opus 4 (claude-opus-4-5-20251101)
Story 待实现 - 等待 dev-story 工作流执行
待实现