ソースを参照

docs(e2e-test-utils): 创建 Story 3.2 - 编写文件上传单元测试

- 创建包含完整验收标准的 Story 文档
- 应用 Epic 2 关键经验:单元测试局限性分析
- 提供详细的测试用例设计(成功场景、错误场景、安全防护)
- 包含 Vitest Mock 策略和测试框架配置
- 更新 sprint-status: 3-2-upload-unit-tests → ready-for-dev

Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 1 週間 前
コミット
2e80f9a80a

+ 425 - 0
_bmad-output/implementation-artifacts/3-2-upload-unit-tests.md

@@ -0,0 +1,425 @@
+# Story 3.2: 编写文件上传单元测试
+
+Status: ready-for-dev
+
+<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
+
+## 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<void>
+
+// 内部辅助函数(需要重点测试)
+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
+
+_待实现_
+

+ 1 - 1
_bmad-output/implementation-artifacts/sprint-status.yaml

@@ -65,7 +65,7 @@ development_status:
   # 模式: 工具开发 → 真实 E2E 测试验证 → 问题修复 → 稳定性验证
   epic-3: in-progress
   3-1-file-upload-tool: done             # 开发文件上传工具函数(含 UI 组件架构改进)
-  3-2-upload-unit-tests: backlog         # 编写文件上传工具的单元测试
+  3-2-upload-unit-tests: ready-for-dev  # 编写文件上传工具的单元测试
   3-3-upload-e2e-integration: backlog    # 在 web/tests/e2e 中验证文件上传工具
   3-4-collect-feedback-fix: backlog      # 收集反馈并修复问题
   3-5-upload-stability-test: backlog     # 文件上传稳定性验证 (10次连续运行)