1-6-select-unit-tests.md 16 KB

Story 1.6: Select 工具函数单元测试

Status: done

Story

作为测试开发者, 我想要 Select 工具函数有充分的单元测试, 以便确保函数的正确性和稳定性。

Acceptance Criteria

Given Select 工具函数已实现(Story 1.3、1.4 完成) When 创建 tests/unit/radix-select.test.ts Then 测试覆盖率 ≥ 80%(NFR29) And 测试用例包括:成功选择、选项不存在、超时、错误处理 And 使用 Vitest 运行测试 And 所有测试通过

Tasks / Subtasks

  • 创建单元测试文件 tests/unit/radix-select.test.ts (AC: 1, 2)
    • 配置 Vitest 环境和必要的 mock
    • 导入 selectRadixOptionselectRadixOptionAsync 函数
  • 实现 selectRadixOption 单元测试 (AC: 3, 4)
    • 测试成功选择静态选项(data-testid 策略)
    • 测试成功选择静态选项(aria-label 策略)
    • 测试成功选择静态选项(text 策略)
    • 测试选项不存在时抛出 E2ETestError
    • 测试触发器未找到时抛出 E2ETestError
    • 测试错误消息包含完整上下文信息
  • 实现 selectRadixOptionAsync 单元测试 (AC: 3, 4)
    • 测试成功选择异步选项(默认配置)
    • 测试自定义超时配置
    • 测试禁用网络空闲等待
    • 测试选项加载超时抛出 E2ETestError
    • 测试重试机制正常工作
  • 配置 Vitest 覆盖率目标 (AC: 1)
    • 确认覆盖率配置 ≥ 80%
    • 生成覆盖率报告
  • 验证测试通过 (AC: 5)
    • 运行 pnpm test:unit 确保所有测试通过
    • 检查覆盖率是否达标

Dev Notes

Epic 1 背景

Epic 1 目标: 测试开发者可以安装 @d8d/e2e-test-utils 包,立即使用 Select 工具测试 Radix UI Select 组件。

本故事在 Epic 中的位置: 第六个故事,为 Select 工具函数创建单元测试,确保代码质量和稳定性。

当前包状态分析

已完成的工作(Story 1.1-1.5):

  1. Story 1.1 - 包基础结构(package.json、tsconfig.json、vitest.config.ts)
  2. Story 1.2 - 类型定义和错误处理(types.ts、errors.ts、constants.ts)
  3. Story 1.3 - 静态 Select 工具(selectRadixOption() 函数)
  4. Story 1.4 - 异步 Select 工具(selectRadixOptionAsync() 函数)
  5. Story 1.5 - 主导出和 README 文档

本故事核心任务: 为 Select 工具函数创建完整的单元测试,确保测试覆盖率 ≥ 80%。

单元测试架构约束

从 Architecture 文档的测试策略:

测试类型 范围 工具 目标
单元测试 单个函数逻辑 Vitest ≥80% 覆盖率
集成测试 与 DOM/浏览器交互 Playwright 验证实际操作
稳定性测试 20次连续运行 Playwright 100% 通过率

本故事专注于单元测试 - 测试纯函数逻辑,不需要真实的浏览器环境。

Vitest 配置

当前配置 (vitest.config.ts):

{
  test: {
    dir: './tests/unit',
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      statements: 80,
      branches: 80,
      functions: 80,
      lines: 80
    },
    testTimeout: 10000
  }
}

关键配置说明:

  • environment: 'node' - 单元测试在 Node.js 环境运行,不需要浏览器
  • coverage.statements: 80 - 语句覆盖率目标 ≥ 80%
  • testTimeout: 10000 - 每个测试超时 10 秒

单元测试设计策略

测试函数的内部逻辑,而非 Playwright API:

单元测试需要 mock Playwright 的 Page 对象,测试以下逻辑:

selectRadixOption 需要测试的逻辑:

  1. 选择器策略优先级(data-testid → aria-label → text)
  2. 触发器查找失败处理
  3. 选项查找失败处理
  4. E2ETestError 抛出和错误消息格式

selectRadixOptionAsync 需要测试的逻辑:

  1. 默认配置合并(timeout、waitForOption、waitForNetworkIdle)
  2. 网络空闲等待处理
  3. 选项加载重试机制
  4. 超时处理

Mock 策略

使用 Vitest 的 vi.mock() 来模拟 Playwright Page 对象:

import { vi, describe, it, expect } from 'vitest';
import { selectRadixOption } from '@d8d/e2e-test-utils';

// Mock Playwright Page 对象
const mockPage = {
  waitForSelector: vi.fn(),
  locator: vi.fn(),
  click: vi.fn(),
  waitForLoadState: vi.fn(),
  waitForTimeout: vi.fn()
} as any;

测试用例设计

selectRadixOption 测试用例:

测试场景 验证内容 Mock 设置
成功选择(data-testid) 触发器和选项被正确点击 waitForSelector 返回元素
成功选择(aria-label) 第二策略生效 第一策略失败,第二成功
成功选择(text) 兜底策略生效 前两策略失败,第三成功
触发器未找到 抛出 E2ETestError 所有策略都失败
选项不存在 抛出 E2ETestError 并包含可用选项 触发器成功,选项失败

selectRadixOptionAsync 测试用例:

测试场景 验证内容 Mock 设置
默认配置成功 使用默认 timeout 和等待选项 waitForLoadState 正常调用
自定义超时 使用传入的 timeout 值 传入 options 对象
禁用网络空闲等待 不调用 waitForLoadState waitForNetworkIdle: false
选项加载超时 抛出超时错误 选项选择器超时
重试机制 多次尝试查找选项 第一次失败,第二次成功

错误处理测试

必须验证 E2ETestError 的完整上下文:

it('should throw E2ETestError with full context when trigger not found', async () => {
  // Mock 所有策略都失败
  mockPage.waitForSelector.mockRejectedValue(new Error('Not found'));

  await expect(selectRadixOption(mockPage, '残疾类型', '视力残疾'))
    .rejects.toThrow(E2ETestError);

  // 验证错误上下文
  try {
    await selectRadixOption(mockPage, '残疾类型', '视力残疾');
  } catch (error) {
    expect(error).toBeInstanceOf(E2ETestError);
    expect(error.context.operation).toBe('selectRadixOption');
    expect(error.context.target).toBe('残疾类型');
    expect(error.context.expected).toBe('视力残疾');
    expect(error.context.suggestion).toContain('data-testid');
  }
});

覆盖率目标

根据 NFR29,测试覆盖率必须 ≥ 80%:

覆盖的代码路径:

  • selectRadixOption:
    • ✅ findTrigger 的三种选择器策略
    • ✅ findAndClickOption 的两种选项查找策略
    • ✅ 错误抛出路径
  • selectRadixOptionAsync:
    • ✅ 配置合并逻辑
    • ✅ 网络空闲等待(可选)
    • ✅ waitForOptionAndSelect 的重试机制
    • ✅ 超时错误路径

运行覆盖率报告:

# 运行测试并生成覆盖率
pnpm test:coverage

# 或使用 Vitest 直接运行
pnpm vitest run --coverage

与前一个故事的集成

Story 1.5 已完成的工作:

  • src/index.ts 导出 selectRadixOptionselectRadixOptionAsync
  • ✅ 完整的 JSDoc 注释包含参数说明和示例
  • ✅ README 文档包含 Select 工具使用示例

本故事需要测试的代码:

  • src/radix-select.ts 中的所有导出函数
  • src/errors.ts 中的 E2ETestErrorthrowError
  • src/constants.ts 中的 DEFAULT_TIMEOUTS 常量使用

前一个故事的关键经验

从 Story 1.3、1.4 中学习到的经验:

  1. 内部函数使用 @internal 标记

    • findTrigger() - 内部函数,不直接测试
    • findAndClickOption() - 内部函数,通过公共函数间接测试
    • waitForOptionAndSelect() - 内部函数,通过异步函数间接测试
  2. 选择器策略优先级

    • 第一优先级:data-testid - 推荐,最稳定
    • 第二优先级:aria-label + role - 无障碍属性
    • 第三优先级:text content - 兜底方案
  3. 错误处理模式

    • 使用 throwError() 辅助函数抛出 E2ETestError
    • 错误消息包含:operation、target、expected、available、suggestion
  4. 超时配置

    • 静态选项:DEFAULT_TIMEOUTS.static = 2000ms
    • 异步选项:DEFAULT_TIMEOUTS.async = 5000ms
    • 网络空闲:DEFAULT_TIMEOUTS.networkIdle = 10000ms

文件结构

测试文件位置:

packages/e2e-test-utils/
├── src/
│   └── radix-select.ts      # 被测试的源文件
├── tests/
│   └── unit/
│       └── radix-select.test.ts  # 本故事创建的测试文件
├── vitest.config.ts          # Vitest 配置(已存在)
└── package.json

导入路径:

// 测试文件中导入被测试函数
import { selectRadixOption, selectRadixOptionAsync } from '@d8d/e2e-test-utils';
import { E2ETestError } from '@d8d/e2e-test-utils';
import { DEFAULT_TIMEOUTS } from '@d8d/e2e-test-utils';

项目标准对齐

与项目标准对齐:

  • 遵循 docs/standards/testing-standards.md 中的测试规范
  • 使用 Vitest 作为单元测试运行器
  • 使用 console.debug 进行调试输出(Vitest 中只有 console.debug 会显示)
  • 测试文件命名:radix-select.test.ts(与源文件同名)

Vitest 特殊规则:

  • Console 输出: 只有 console.debug 会显示,console.log 被屏蔽
  • Mock: 使用 vi.mocked() 配合 import 语句,不用 require
  • 运行测试: pnpm test --testNamePattern "测试名称" 运行特定测试

前一个 Story 的智能信息

从 Story 1.5 中获取的上下文:

  1. README 文档位置: packages/e2e-test-utils/README.md
  2. 导出结构已确认正确: src/index.ts 使用显式命名导出
  3. JSDoc 注释完整: 所有公共 API 都有完整文档
  4. 静态 vs 异步 Select 区分: README 包含详细对比表格

从 Story 1.4 中学到的经验:

  • 网络空闲等待失败不应中断流程(某些场景网络可能始终不空闲)
  • 重试机制使用 100ms 间隔
  • 超时后需要获取可用选项用于错误提示

性能约束

从 NFR 提取的性能要求:

  • NFR8: 单个 Radix UI Select 选择操作(静态)应在 2 秒内完成
  • NFR9: 单个 Radix UI Select 选择操作(异步)应在 5 秒内完成
  • NFR10: 工具函数本身的开销不超过 100ms

单元测试验证:

  • 测试执行时间应符合性能标准
  • 覆盖率报告应包含执行时间统计

错误消息标准

从 Architecture 文档的错误处理策略:

❌ selectOption failed
Target: 残疾类型
Expected: 视力残疾
Available: 听力残疾, 言语残疾, 肢体残疾
💡 Suggestion: Check if the option value matches exactly

测试必须验证:

  1. 错误类型是 E2ETestError
  2. 错误包含完整的 ErrorContext
  3. 错误消息格式符合标准

Project Structure Notes

对齐项目 Monorepo 架构:

  • 包位于 packages/e2e-test-utils/
  • 使用 workspace 协议安装:@d8d/e2e-test-utils@workspace:*
  • 测试文件位于 tests/unit/ 目录

与项目标准对齐:

  • 遵循 docs/standards/testing-standards.md 中的测试规范
  • 遵循 docs/standards/e2e-radix-testing.md 中的 Radix UI E2E 测试标准

References

PRD 来源:

Architecture 来源:

标准文档来源:

Epic 来源:

前一个故事:

Dev Agent Record

Agent Model Used

Claude (d8d-model) via create-story workflow

Debug Log References

Completion Notes List

  • 故事创建时间: 2026-01-09
  • 基于 PRD、Architecture、E2E Radix 测试标准文档创建
  • 基于 Story 1.1-1.5 的实现状态创建
  • 包含完整的单元测试设计指南和 Mock 策略
  • 包含测试用例设计表格和覆盖率目标

实现完成时间: 2026-01-09

代码审查修复 (2026-01-09):

  • ✅ 添加了 index.ts 导出测试(8 个测试用例验证主导出配置)
  • ✅ 添加了重试机制测试(2 个测试用例验证重试逻辑)
  • ✅ 改进了错误处理测试深度(验证 suggestion 和 available 字段)
  • ✅ 修复了 vitest.config.ts 覆盖率配置(排除 dist/、.d.ts、config 文件)
  • ✅ 测试通过:37/37 (新增 10 个测试)
  • ✅ 覆盖率达到要求:Statements 93.65%, Branches 88.09%, Functions 100%

实际实现:

  • ✅ 创建了 tests/unit/radix-select.test.ts 单元测试文件
  • ✅ 实现了 24 个测试用例(静态 + 异步 + 导出验证 + 重试机制)
  • ✅ 所有测试通过 (37/37,包括 index.test.ts 中的 13 个测试)
  • ✅ 覆盖率达到要求:Statements 93.65%, Branches 88.09%, Functions 100%
  • ✅ 修复了源代码中的一个 bug(selectRadixOptionAsync 的网络空闲等待超时应该使用自定义 timeout)

测试覆盖的功能:

  1. selectRadixOption:

    • data-testid 策略成功选择
    • aria-label 策略成功选择
    • text 策略成功选择
    • 触发器未找到错误处理
    • 选项不存在错误处理
    • 错误消息包含完整上下文
  2. selectRadixOptionAsync:

    • 默认配置成功选择
    • 自定义超时配置
    • 禁用网络空闲等待
    • 选项加载超时错误处理
    • 超时错误包含重试建议
    • 触发器未找到错误处理
    • 选项查找策略优先级

实现建议:

  • 使用 Vitest 的 vi.fn()vi.mocked() 来 mock Playwright Page 对象
  • 测试选择器策略的优先级顺序(data-testid → aria-label → text)
  • 验证 E2ETestError 的完整上下文信息
  • 确保覆盖率 ≥ 80%(NFR29)

关键检查点:

  • ✅ 单元测试使用 Vitest 运行(非 Playwright)
  • ✅ Mock Page 对象,不依赖真实浏览器
  • ✅ 测试所有代码路径(成功、失败、错误)
  • ✅ 验证错误消息格式和上下文完整性

File List

本故事创建的文件:

  • packages/e2e-test-utils/tests/unit/radix-select.test.ts - Select 工具函数的单元测试

本故事修改的文件:

  • packages/e2e-test-utils/src/radix-select.ts - 修复了 selectRadixOptionAsync 中网络空闲等待使用自定义 timeout 的 bug
  • packages/e2e-test-utils/vitest.config.ts - 修复覆盖率配置,排除 dist/、*.d.ts、vitest.config.ts 等非源代码文件

相关文件(已在 Story 1.1-1.4 中完成,本故事测试):

  • packages/e2e-test-utils/src/types.ts - 类型定义
  • packages/e2e-test-utils/src/errors.ts - 错误类(被测试)
  • packages/e2e-test-utils/src/constants.ts - 常量定义
  • packages/e2e-test-utils/vitest.config.ts - Vitest 配置(已存在)

只读参考文件:

  • _bmad-output/implementation-artifacts/1-3-static-select-tool.md - 静态 Select 实现
  • _bmad-output/implementation-artifacts/1-4-async-select-tool.md - 异步 Select 实现
  • _bmad-output/planning-artifacts/epics.md - Epic 和故事定义
  • _bmad-output/planning-artifacts/architecture.md - 架构决策和测试策略
  • docs/standards/e2e-radix-testing.md - E2E Radix UI 测试标准