# Story 3.2: 编写文件上传单元测试 Status: ready-for-dev ## Story 作为测试开发者, 我想要文件上传工具有充分的单元测试, 以便确保函数的正确性和稳定性。 ## Acceptance Criteria **Given** 文件上传工具函数已实现(`packages/e2e-test-utils/src/file-upload.ts`) **When** 创建 `tests/unit/file-upload.test.ts` **Then** 验收标准如下: 1. **测试覆盖率 ≥ 80%(NFR29)** - 覆盖所有分支路径 - 覆盖所有错误处理场景 - 覆盖边界条件 2. **测试用例包括:成功上传、文件不存在、选择器无效、超时** - 成功上传场景 - 文件不存在场景 - 选择器无效场景 - 超时配置场景 - 路径遍历攻击防护测试 - 自定义 fixtures 目录测试 3. **使用 Vitest 运行测试** - 使用 `vitest` 作为测试框架 - 测试文件位于 `tests/unit/file-upload.test.ts` 4. **所有测试通过** - 所有测试用例必须通过 - 无 flaky 失败 ## Tasks / Subtasks - [ ] **Task 1: 创建测试文件目录结构** (AC: #3) - [ ] Subtask 1.1: 创建 `tests/unit/` 目录(如果不存在) - [ ] Subtask 1.2: 创建 `tests/fixtures/` 测试资源目录(如果不存在) - [ ] **Task 2: 实现文件上传成功场景测试** (AC: #2) - [ ] Subtask 2.1: 测试默认 fixtures 目录的文件上传 - [ ] Subtask 2.2: 测试自定义 fixtures 目录的文件上传 - [ ] Subtask 2.3: 测试子目录文件上传(如 `images/sample.jpg`) - [ ] Subtask 2.4: 验证 `setInputFiles` API 被正确调用 - [ ] **Task 3: 实现错误场景测试** (AC: #2) - [ ] Subtask 3.1: 测试文件不存在错误(抛出 `E2ETestError`) - [ ] Subtask 3.2: 测试选择器无效错误(抛出 `E2ETestError`) - [ ] Subtask 3.3: 验证错误消息包含正确的上下文信息 - [ ] **Task 4: 实现边界条件和安全测试** (AC: #2) - [ ] Subtask 4.1: 测试路径遍历攻击防护(`../` 路径被拒绝) - [ ] Subtask 4.2: 测试绝对路径被拒绝 - [ ] Subtask 4.3: 测试路径遍历验证(解析后的路径在 fixtures 目录内) - [ ] Subtask 4.4: 测试超时配置生效 - [ ] **Task 5: 创建测试 fixtures 文件** (AC: #1, #2) - [ ] Subtask 5.1: 创建 `tests/fixtures/images/test-sample.jpg` 占位文件 - [ ] Subtask 5.2: 创建 `tests/fixtures/documents/test-sample.pdf` 占位文件 - [ ] **Task 6: 运行测试并验证覆盖率** (AC: #1, #4) - [ ] Subtask 6.1: 运行 `pnpm test --testNamePattern "file-upload"` 验证测试通过 - [ ] Subtask 6.2: 运行 `pnpm test:coverage` 验证覆盖率 ≥ 80% - [ ] Subtask 6.3: 修复覆盖率不足的分支 - [ ] **Task 7: 添加测试文档和注释** (NFR25-NFR40) - [ ] Subtask 7.1: 为每个测试用例添加清晰的描述 - [ ] Subtask 7.2: 添加测试场景说明注释 ## Dev Notes ### Epic 3 背景与目标 **Epic 3: 文件上传工具开发与验证** 遵循 Epic 2 的成功模式,开发文件上传工具并在真实 E2E 测试中验证,解决当前测试超时阻塞问题。 **模式:** 工具开发 → 真实 E2E 测试验证 → 问题修复 → 稳定性验证 **当前进度:** - Story 3.1: ✅ 已完成 - `uploadFileToField()` 函数已实现 - Story 3.2: 🔄 本 Story - 编写单元测试 ### ⚠️ Epic 2 关键经验应用(必须阅读) **单元测试的局限性(来自 Epic 2 回顾):** 1. **单元测试无法发现真实 DOM 问题** - Epic 2 的 Select 工具单元测试覆盖率 93.65%,仍然无法发现 DOM 结构问题 - 原因:单元测试使用模拟 DOM,不是真实的 Radix UI 组件 - 教训:不能仅依赖单元测试验证工具正确性 2. **真实 E2E 测试不可替代** - DOM 结构假设必须基于真实组件验证 - 选择器策略必须在真实浏览器中测试 - 集成测试是必需的,不是可选项 3. **本 Story 的定位** - 单元测试用于验证基本逻辑(路径解析、错误处理、边界条件) - **不能**替代 Story 3.3 的真实 E2E 集成测试 - 重点测试:`resolveFixturePath()` 函数逻辑、错误抛出、安全防护 ### 技术规范 #### 被测函数分析 **`uploadFileToField()` 函数结构:** ```typescript // packages/e2e-test-utils/src/file-upload.ts export async function uploadFileToField( page: Page, selector: string, fileName: string, options?: FileUploadOptions ): Promise // 内部辅助函数(需要重点测试) function resolveFixturePath(fileName: string, fixturesDir: string): string ``` **关键测试点:** 1. **`resolveFixturePath()` 函数逻辑** - 核心测试重点 - 拒绝绝对路径 - 拒绝包含 `..` 的路径 - 验证解析后的路径在 fixtures 目录内(防止路径遍历攻击) - 正确解析相对路径 2. **错误处理** - 文件不存在时抛出 `E2ETestError` - 选择器无效时抛出 `E2ETestError` - 错误消息包含正确的上下文信息 3. **Playwright API 交互** - 单元测试中的模拟 - `page.locator(selector)` 返回 Locator 对象 - `locator.setInputFiles(filePath, { timeout })` 被调用 - `page.waitForTimeout(200)` 在 `waitForUpload: true` 时被调用 #### Vitest 配置 **配置文件:** `packages/e2e-test-utils/vitest.config.ts` ```typescript { test: { dir: './tests/unit', // 单元测试目录 environment: 'node', // Node.js 环境 testTimeout: 10000, // 10秒超时 coverage: { statements: 80, // 语句覆盖率目标 branches: 80, // 分支覆盖率目标 functions: 80, // 函数覆盖率目标 lines: 80 // 行覆盖率目标 } } } ``` #### 测试 Mock 策略 **Mock Playwright Page 对象:** ```typescript 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) }; ``` ### 测试用例设计 #### 场景 1: 成功上传(默认 fixtures 目录) **测试目标:** 验证基本文件上传流程 ```typescript 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 } ); }); ``` #### 场景 2: 成功上传(自定义 fixtures 目录) **测试目标:** 验证自定义 fixtures 目录配置 ```typescript 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() ); }); ``` #### 场景 3: 文件不存在错误 **测试目标:** 验证文件不存在时抛出正确的错误 ```typescript it('应该在文件不存在时抛出 E2ETestError', async () => { // Arrange const nonExistentFile = 'non-existent-file.jpg'; // Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', nonExistentFile) ).rejects.toThrow(E2ETestError); }); ``` #### 场景 4: 选择器无效错误 **测试目标:** 验证选择器无效时抛出正确的错误 ```typescript 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); }); ``` #### 场景 5: 路径遍历攻击防护 **测试目标:** 验证安全防护机制 ```typescript 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); }); ``` #### 场景 6: 超时配置 **测试目标:** 验证自定义超时生效 ```typescript 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 测试) | ### 运行测试命令 **运行单元测试:** ```bash # 在 packages/e2e-test-utils 目录下 pnpm test --testNamePattern "file-upload" # 从项目根目录 pnpm --filter @d8d/e2e-test-utils test --testNamePattern "file-upload" ``` **运行覆盖率测试:** ```bash 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 结构假设必须验证 ### 已实现的源文件参考 **被测文件:** - [Source: packages/e2e-test-utils/src/file-upload.ts] **相关类型:** - [Source: packages/e2e-test-utils/src/types.ts] - FileUploadOptions 接口 **错误处理:** - [Source: packages/e2e-test-utils/src/errors.ts] - E2ETestError 类 **常量定义:** - [Source: packages/e2e-test-utils/src/constants.ts] - DEFAULT_TIMEOUTS ### Project Structure Notes **Monorepo 结构对齐:** - 包位于 `packages/e2e-test-utils/` - 使用 pnpm workspace 协议 - 测试框架:Vitest(单元测试) **文件组织:** - 测试文件位于 `tests/unit/` 目录 - 测试资源位于 `tests/fixtures/` 目录 - 使用 `.test.ts` 后缀命名 ### References **源文档引用:** - [Source: _bmad-output/planning-artifacts/epics.md#Epic-3-Story-3.2] - [Source: _bmad-output/planning-artifacts/architecture.md#Testing-Configuration] - [Source: _bmad-output/implementation-artifacts/3-1-file-upload-tool.md] - Story 3.1 实现经验 **Epic 2 经验教训:** - [Source: _bmad-output/implementation-artifacts/epic-2-retrospective.md] - 单元测试局限性分析 ## Dev Agent Record ### Agent Model Used _Claude Opus 4 (claude-opus-4-5-20251101)_ ### Debug Log References ### Completion Notes List _Story 待实现 - 等待 dev-story 工作流执行_ ### File List _待实现_