2
0

3-2-upload-unit-tests.md 12 KB

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() 函数结构:

// 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

{
  test: {
    dir: './tests/unit',          // 单元测试目录
    environment: 'node',          // Node.js 环境
    testTimeout: 10000,           // 10秒超时
    coverage: {
      statements: 80,             // 语句覆盖率目标
      branches: 80,               // 分支覆盖率目标
      functions: 80,              // 函数覆盖率目标
      lines: 80                   // 行覆盖率目标
    }
  }
}

测试 Mock 策略

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)
};

测试用例设计

场景 1: 成功上传(默认 fixtures 目录)

测试目标: 验证基本文件上传流程

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 目录配置

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: 文件不存在错误

测试目标: 验证文件不存在时抛出正确的错误

it('应该在文件不存在时抛出 E2ETestError', async () => {
  // Arrange
  const nonExistentFile = 'non-existent-file.jpg';

  // Act & Assert
  await expect(
    uploadFileToField(mockPage, 'photo-upload', nonExistentFile)
  ).rejects.toThrow(E2ETestError);
});

场景 4: 选择器无效错误

测试目标: 验证选择器无效时抛出正确的错误

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: 路径遍历攻击防护

测试目标: 验证安全防护机制

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: 超时配置

测试目标: 验证自定义超时生效

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 结构假设必须验证

已实现的源文件参考

被测文件:

  • [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

待实现