🔥【免费下载链接】Front-End-Checklist
🗂 The essential checklist for modern web development, for humans and AI agents
单元测试是 Front-End-Checklist 中优先级为 high 的核心规则之一,本指南围绕 skills/unit-tests/references/rule.md 展开,系统讲解单元测试的配置、编写策略与落地流程,并结合本仓库真实的 Jest 测试体系(统一覆盖率阈值、setup 文件、纯函数与组件测试案例)进行源码级佐证。读完本文,你将掌握一套可复制的单元测试方案:从零搭建 Vitest/Jest 测试环境,为纯函数、React 组件、自定义 Hooks 与异步逻辑编写高质量测试,并把覆盖率门槛与 CI 检查固化到日常开发流程中。
为什么单元测试如此重要
单元测试(Unit Tests)在隔离环境中验证单个函数与组件是否按预期工作。它带来的核心价值有三点:
- 在代码进入生产环境之前拦截缺陷:越早发现问题,修复成本越低;
- 充当行为文档:测试用例本身就是对"这段代码应该做什么"的可执行描述,新成员可以通过阅读测试快速理解模块契约;
- 给重构以信心:当需要调整内部实现时,一套可靠的测试能确保行为不被破坏。
Front-End-Checklist 把"关键功能有单元测试且覆盖率良好"列为高优先级规则,对应技能入口见 skills/unit-tests/SKILL.md,其中明确要求:"测试业务逻辑、工具函数与边界情况;关键路径覆盖率目标 80% 以上;使用描述行为的测试名称;mock 外部依赖而非内部模块;在 CI 中运行测试以尽早捕获回归。"
测试优先级:先测什么,再测什么
并非所有代码都值得同等的测试投入。原文档给出的优先级矩阵是:
| 优先级 | 示例 | 原因 |
|---|---|---|
| Critical | 支付金额计算 | 财务准确性 |
| High | 表单校验 | 用户数据完整性 |
| High | 认证逻辑 | 安全性 |
| Medium | 数据转换 | 业务逻辑 |
| Medium | 工具函数 | 可复用代码 |
| Low | 简单 getter | 很少出错 |
这个矩阵的排序原则是"出错代价越高、越容易被改动破坏的代码,优先级越高"。支付计算、表单校验、认证逻辑这类涉及金钱、数据与安全的路径,一旦出错直接影响用户信任,必须优先覆盖;而简单 getter 几乎不会变化,盲目追求覆盖率反而是浪费。
测试环境配置:从零搭建 Vitest
原文档给出了基于 Vitest 的完整配置方案,这是 React + TypeScript 项目最常见的选择。
vitest.config.ts
// vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from 'node:path' export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], include: ['**/*.{test,spec}.{ts,tsx}'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*', '**/types/*', ], thresholds: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, }, }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, })关键配置项说明:
environment: 'jsdom':提供 DOM 环境,让组件测试可以渲染真实 DOM 结构;globals: true:启用全局describe/it/expect,避免在每个文件重复导入;setupFiles:在测试运行前加载的初始化脚本,常用于注册清理逻辑与浏览器 API mock;include:测试文件匹配模式,默认覆盖*.test.ts(x)与*.spec.ts(x);coverage.provider: 'v8':使用 V8 引擎原生覆盖率工具,无需额外转译开销;coverage.exclude:排除node_modules/、构建产物、类型声明与配置文件,避免稀释真实代码的覆盖率数字;coverage.thresholds.global:全局覆盖率门槛,任何维度低于 80% 都会让测试命令以失败退出,这是把"覆盖率"从口号变成硬约束的关键;resolve.alias:把@/指向src/,保持测试代码与业务代码的导入路径一致。
vitest.setup.ts
// vitest.setup.ts import { cleanup } from '@testing-library/react' import { afterEach, vi } from 'vitest' afterEach(() => { cleanup() vi.clearAllMocks() }) // Mock window.matchMedia Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), })cleanup()保证每个用例结束后卸载已渲染的组件,vi.clearAllMocks()清除 mock 的调用记录防止用例间互相污染;matchMedia是绝大多数组件库(暗色模式、响应式逻辑)都会触碰的浏览器 API,在 jsdom 中默认不存在,必须在 setup 阶段补齐。
仓库的 Jest 落地:统一配置工厂与覆盖率门槛
本仓库的单元测试体系基于Jest + Testing Library而非 Vitest,但其设计思路与上述 Vitest 配置完全同构,可作为对照参考。根目录 jest.base.cjs 是整个 monorepo 的测试配置源头,定义了全局覆盖率门槛与报告器:
const COVERAGE_THRESHOLD = { branches: 70, functions: 80, lines: 80, statements: 80 } const COVERAGE_REPORTERS = ['text', 'lcov', 'html']可以看出,仓库把 functions/lines/statements 的及格线设为80%(与规则文档中的推荐值一致),branches 设为 70%,并产出text(终端)、lcov(CI 上报)、html(本地可视化)三种报告。同时提供createPackageJestConfig()工厂函数,各 package 只需一行配置即可继承统一规范。例如 packages/utils/jest.config.js:
const { createPackageJestConfig } = require('../../jest.base.cjs') module.exports = createPackageJestConfig({ testEnvironment: 'node', testMatch: ['**/__tests__/**/*.test.ts'], collectCoverageFrom: [ 'src/cn.ts', 'src/debounce.ts', 'src/formatDate.ts', 'src/formatTechTerm.ts' ] })而 Web 应用层(Next.js)的 Jest 配置在 apps/web/jest.config.cjs,通过next/jest预设加载next.config.js,并做了三件关键事情:
- jsdom 测试环境+
@testing-library/jest-dom断言扩展(见 apps/web/jest.setup.cjs); - 模块别名映射:
^@/(.*)$ → <rootDir>/$1,以及把@repo/*workspace 包指向对应src源码,保证测试直接覆盖源码而非构建产物; - 排除 e2e 目录:
testPathIgnorePatterns排除.next/、node_modules/、e2e/,让单元测试与 Playwright 端到端测试各司其职。
setup 文件中还示范了"按需补齐浏览器 API"的做法——为 jsdom 注入TextEncoder/TextDecoder,mocknext/cache,并补上matchMedia,与规则文档中vitest.setup.ts的职责完全对应:
if (typeof window !== 'undefined' && !window.matchMedia) { Object.defineProperty(window, 'matchMedia', { writable: true, value: query => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false }) }) }从源码结构看,jest.base.cjs之所以把覆盖率门槛集中在一处,正是为了让所有 package 的测试行为保持一致,避免每个包各自定义松弛的标准——这本身就是"覆盖率门槛"这一最佳实践的工程化体现。
测试纯函数:格式化函数实战
纯函数(同样的输入必然得到同样的输出、无副作用)是单元测试的最佳对象,测试成本最低、价值最直接。原文档以货币与日期格式化为例:
// utils/formatters.ts export function formatCurrency( amount: number, currency: string = 'USD' ): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency, }).format(amount) } export function formatDate(date: Date | string): string { const d = typeof date === 'string' ? new Date(date) : date return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric', }).format(d) }// utils/formatters.test.ts import { describe, it, expect } from 'vitest' import { formatCurrency, formatDate } from './formatters' describe('formatCurrency', () => { it('formats USD by default', () => { expect(formatCurrency(1234.56)).toBe('$1,234.56') }) it('formats other currencies', () => { expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56') expect(formatCurrency(1234.56, 'GBP')).toBe('£1,234.56') }) it('handles zero', () => { expect(formatCurrency(0)).toBe('$0.00') }) it('handles negative amounts', () => { expect(formatCurrency(-50)).toBe('-$50.00') }) it('rounds to two decimal places', () => { expect(formatCurrency(10.999)).toBe('$11.00') }) }) describe('formatDate', () => { it('formats Date objects', () => { const date = new Date('2024-01-15') expect(formatDate(date)).toBe('January 15, 2024') }) it('formats date strings', () => { expect(formatDate('2024-06-20')).toBe('June 20, 2024') }) })这段测试覆盖了默认值、参数化(多币种)、边界值(零)、负数、舍入行为、多输入类型(Date 对象与字符串)——正好对应规则文档 Testing Checklist 中的"happy path、edge cases、边界值"要求。注意每个用例只断言一个行为维度,断言失败时能立刻定位到具体语义。
仓库中 packages/utils/src/tests/utils.test.ts 是同款思路的真实落地,它测试了cn(tailwind 类名合并优先级)、formatDate、formatTechTerm与debounce四个纯函数。其中防抖测试非常值得学习,它用假定时器精确验证"时间边界"这一典型边界条件:
it('debounces repeated invocations', () => { jest.useFakeTimers() const fn = jest.fn() const debounced = debounce(fn, 100) debounced('first') debounced('second') jest.advanceTimersByTime(99) expect(fn).not.toHaveBeenCalled() jest.advanceTimersByTime(1) expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledWith('second') jest.useRealTimers() })在 99ms 时断言"尚未触发",推进到 100ms 整时断言"恰好触发一次且参数为最后一次调用的值"——这既验证了防抖的延迟语义,也验证了"只保留最后一次调用"的核心行为。这就是"测试行为而非实现细节"的范本。
测试 React 组件:交互与状态
组件测试通过 Testing Library 以真实用户视角(role、text、attribute)进行断言。原文档以 Button 组件为例:
// components/Button.tsx import React from 'react' interface ButtonProps { children: React.ReactNode onClick?: () => void disabled?: boolean loading?: boolean variant?: 'primary' | 'secondary' } export function Button({ children, onClick, disabled = false, loading = false, variant = 'primary', }: ButtonProps) { return ( <button type="button" onClick={onClick} disabled={disabled || loading} className={`btn btn-${variant}`} aria-busy={loading} > {loading ? 'Loading...' : children} </button> ) }// components/Button.test.tsx import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Button } from './Button' describe('Button', () => { it('renders children', () => { render(<Button>Click me</Button>) expect(screen.getByRole('button')).toHaveTextContent('Click me') }) it('calls onClick when clicked', async () => { const user = userEvent.setup() const handleClick = vi.fn() render(<Button>Click me</Button>) await user.click(screen.getByRole('button')) expect(handleClick).toHaveBeenCalledTimes(1) }) it('is disabled when disabled prop is true', () => { render(<Button>Click me</Button>) expect(screen.getByRole('button')).toBeDisabled() }) it('shows loading state', () => { render(<Button>Submit</Button>) const button = screen.getByRole('button') expect(button).toHaveTextContent('Loading...') expect(button).toHaveAttribute('aria-busy', 'true') expect(button).toBeDisabled() }) it('applies variant class', () => { render(<Button>Cancel</Button>) expect(screen.getByRole('button')).toHaveClass('btn-secondary') }) it('does not call onClick when disabled', async () => { const user = userEvent.setup() const handleClick = vi.fn() render(<Button>Click me</Button>) await user.click(screen.getByRole('button')) expect(handleClick).not.toHaveBeenCalled() }) })组件测试的几个要点:
- 优先用
getByRole而非getByText定位元素,前者更贴近真实用户与无障碍语义; - 交互事件用
userEvent而非fireEvent,因为userEvent会模拟完整事件序列(focus、keydown、click 等),更接近真实浏览器行为; - 把"是否触发回调"与"是否触发回调时的禁用态"分开成独立用例,语义清晰;
- 通过
aria-busy这类无障碍属性断言 loading 状态,恰好满足原文档 Testing Checklist 中"测试可访问性(ARIA 属性、roles)"的要求。
仓库中 packages/design-system/src/tests/design-system.test.tsx 是组件测试的真实示例,它同时验证了 package 子路径导出、Badge 变体渲染、Button 的变体与插槽(slot)行为:
it('renders buttons with variants and slot children', () => { render( // <Button> 搭配 AsChild / slot 组合渲染 ) // 断言变体 class 与内容 })它使用@testing-library/react的render、screen、fireEvent与act,与规则文档推荐的 Testing Library 方案一脉相承("Check the implementation against Testing Library Guiding Principles before treating the rule as satisfied")。
测试自定义 Hooks:借助 renderHook 与 act
自定义 Hook 不能直接渲染,需要用renderHook挂载,并通过act包裹状态更新:
// hooks/useCounter.ts import { useCallback, useState } from 'react' export function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue) const increment = useCallback(() => setCount((c) => c + 1), []) const decrement = useCallback(() => setCount((c) => c - 1), []) const reset = useCallback(() => setCount(initialValue), [initialValue]) return { count, increment, decrement, reset } }// hooks/useCounter.test.ts import { describe, it, expect, act } from 'vitest' import { renderHook } from '@testing-library/react' import { useCounter } from './useCounter' describe('useCounter', () => { it('initializes with default value', () => { const { result } = renderHook(() => useCounter()) expect(result.current.count).toBe(0) }) it('initializes with provided value', () => { const { result } = renderHook(() => useCounter(10)) expect(result.current.count).toBe(10) }) it('increments count', () => { const { result } = renderHook(() => useCounter(0)) act(() => { result.current.increment() }) expect(result.current.count).toBe(1) }) it('decrements count', () => { const { result } = renderHook(() => useCounter(5)) act(() => { result.current.decrement() }) expect(result.current.count).toBe(4) }) it('resets to initial value', () => { const { result } = renderHook(() => useCounter(10)) act(() => { result.current.increment() result.current.increment() result.current.reset() }) expect(result.current.count).toBe(10) }) })要点:result.current持有 Hook 最新返回值;任何触发状态更新的调用都必须包在act()中,否则 React 会告警且断言可能拿到过期状态;同时测试默认值与显式传参两条初始化路径,以及每个操作函数的独立行为。
测试异步函数:stub 全局 fetch
网络请求测试的核心是"不发起真实请求、完全控制响应":
// services/api.ts export async function fetchUser(id: string) { const response = await fetch(`/api/users/${id}`) if (!response.ok) { throw new Error(`User not found: ${id}`) } return response.json() }// services/api.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' describe('fetchUser', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) }) afterEach(() => { vi.unstubAllGlobals() }) it('returns user data on success', async () => { const mockUser = { id: '1', name: 'John' } vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), } as Response) const result = await fetchUser('1') expect(result).toEqual(mockUser) expect(fetch).toHaveBeenCalledWith('/api/users/1') }) it('throws error when user not found', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, } as Response) await expect(fetchUser('999')).rejects.toThrow('User not found: 999') }) })成功用例既断言返回数据,又断言请求参数(URL 拼接正确);失败用例断言rejects.toThrow的错误信息。vi.stubGlobal/vi.unstubAllGlobals在用例前后成对出现,保证全局 fetch 不被污染。
Mock 最佳实践
原文档汇总了五类最常用的 mock 手段:
// Mock external modules vi.mock('@/services/analytics', () => ({ trackEvent: vi.fn(), })) // Mock environment variables vi.stubEnv('API_URL', 'https://test-api.com') // Mock timers vi.useFakeTimers() await vi.advanceTimersByTimeAsync(1000) vi.useRealTimers() // Mock implementations const mockFn = vi.fn().mockImplementation((x) => x * 2) // Spy on methods const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) // ... test spy.mockRestore()实践原则:
- 模块级 mock(
vi.mock)用于替换外部服务(分析 SDK、认证客户端等),让测试聚焦被测单元自身逻辑; - 环境变量(
vi.stubEnv)让 CI 与本地环境行为一致; - 假定时器(
vi.useFakeTimers)让 debounce、轮询、延迟等时间敏感逻辑可瞬时推进、稳定断言; - Spy(
vi.spyOn)用于观察真实方法的调用情况,测试后必须mockRestore还原; - 核心原则是:mock 外部依赖(网络、时间、浏览器 API、第三方 SDK),但不要 mock 内部模块——mock 内部模块会让测试与真实实现脱节,导致"测试全绿、功能全红"的假象。
测试组织:用 describe 分层描述行为
测试命名与组织本身就是文档。原文档给出的推荐结构是按"被测对象 → 方法 → 具体行为"三层嵌套:
// ✅ Good: Descriptive test structure describe('ShoppingCart', () => { describe('addItem', () => { it('adds new item to empty cart', () => {}) it('increases quantity for existing item', () => {}) it('throws error for invalid quantity', () => {}) }) describe('removeItem', () => { it('removes item from cart', () => {}) it('does nothing if item not in cart', () => {}) }) describe('calculateTotal', () => { it('returns 0 for empty cart', () => {}) it('sums prices of all items', () => {}) it('applies discount codes', () => {}) }) })命名规范是"主语(对象)+ 动作(方法)+ 期望结果(行为)"。一个用例失败时,测试报告会呈现完整的上下文链:ShoppingCart › addItem › throws error for invalid quantity,无需读代码就能定位问题点。仓库中各 package 的__tests__目录(如 packages/utils/src/tests/utils.test.ts、packages/design-system/src/tests/design-system.test.tsx)均采用*.test.ts(x)命名与describe/it结构,并遵循 "AAA(Arrange-Act-Assert)" 排列。
CI 集成:让测试挡住每一次回归
测试不进 CI 就没有威慑力。原文档给出的 GitHub Actions 工作流是标准模板:
# .github/workflows/test.yml name: Tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 8 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm test:coverage - name: Upload coverage uses: codecov/codecov-action@v4 with: files: coverage/coverage-final.json本仓库的 Web 应用在 apps/web/package.json 中提供了对应的本地脚本矩阵,含义与 CI 各步骤一一对应:
{ "scripts": { "test": "jest --watchman=false --passWithNoTests", "test:ci": "jest --watchman=false --ci --maxWorkers=1 --passWithNoTests", "test:coverage": "jest --watchman=false --coverage --passWithNoTests", "test:coverage:check": "jest --watchman=false --coverage --coverageReporters=text-summary --coverageReporters=json-summary --passWithNoTests", "test:related": "jest --watchman=false --findRelatedTests --passWithNoTests" } }test:本地开发模式(watch 默认关闭,适合直接运行);test:ci:CI 专用,--ci关闭交互、--maxWorkers=1控制资源占用,保证结果可复现;test:coverage:本地查看覆盖率详情;test:coverage:check:以text-summary+json-summary形式输出,便于 CI 解析并作为质量门禁;test:related:--findRelatedTests只跑与变更文件相关的测试,适合 pre-commit 钩子做快速反馈。
覆盖率未达门槛(根目录 jest.base.cjs 中的 80% 阈值)时,jest --coverage会以非零退出码结束,CI 流水线随之失败——这正是"失败必须阻断回归"的落地方式。
常用测试模式速查
| 模式 | 适用场景 | 示例 |
|---|---|---|
| AAA | 大多数测试 | Arrange(准备)、Act(执行)、Assert(断言) |
| Given-When-Then | BDD 风格 | 用行为描述替代实现描述 |
| Test doubles | 外部依赖 | Mocks、stubs、spies |
| Parameterized | 多组输入 | it.each([...]) |
| Snapshot | UI / 大输出 | expect().toMatchSnapshot() |
其中参数化测试(it.each)尤其适合"同一逻辑、多组数据"的场景,例如校验函数可以一次性列出合法/非法/边界值若干组输入,显著降低重复代码。
覆盖率脚本与门槛配置
// package.json { "scripts": { "test": "vitest", "test:coverage": "vitest run --coverage", "test:watch": "vitest --watch" } }覆盖率数字本身不是目的。原文档特别强调:"高覆盖率不等于高质量。与其追逐任意的覆盖率数字,不如聚焦关键路径与边界情况;测试行为,而非实现细节。" 本仓库的设计也印证了这一点:jest.base.cjs将 branches 门槛(70%)有意设置得低于其他维度(80%),正是因为分支覆盖在某些场景下投入产出比低,硬性拉高反而诱导开发者写"为覆盖而覆盖"的用例。
测试清单:交付前的自查项
在合并代码前,逐项核对:
- ✅ 测试 happy path(期望输入)
- ✅ 测试边界情况(空值、null、边界值)
- ✅ 测试错误条件(非法输入)
- ✅ 测试异步行为(loading、成功、失败状态)
- ✅ 测试可访问性(ARIA 属性、roles)
- ✅ 测试用户交互(click、type、submit)
- ✅ 在每次 PR 的 CI 中运行测试
标准与验证
执行标准:本规则要求测试/监控策略在交付工作流中持续生效,而不是"文档写到了就算完成"。落实时对照两条外部标准自检:对照 Playwright 文档核对实现(涉及 E2E 部分时),对照 Testing Library Guiding Principles 核对组件测试的断言方式(是否以用户视角、是否测试行为而非实现)。
自动化验证:
- 至少为改动影响的一条主路径和一个边界情况编写测试;
- 在可用的浏览器或 CI 工具中验证修复,确认测试确实在真实环境中运行。
人工验证:
- 确认规则在最终渲染输出或运行时行为中生效;
- 复查共享抽象层,确保修复在一致的位置统一应用,而不是只修了单个调用点。
小结
围绕 skills/unit-tests/references/rule.md,完整的单元测试落地路径可以归纳为五步:搭环境(Vitest 或本仓库的 Jest 统一工厂配置,含 setup 与 matchMedia 等浏览器 API 补齐)→排优先级(按出错代价从支付/表单/认证向下排序)→分层覆盖(纯函数 → 组件 → Hooks → 异步)→定门槛(80% 覆盖率阈值,低分支门槛取舍)→进 CI(test:ci/test:coverage:check脚本 + 覆盖率门禁)。记住那句贯穿始终的忠告:高覆盖率不等于高质量,测试的关键路径与边界情况,测试行为而非实现细节——这才是让单元测试真正成为"可靠性保障"而非"KPI 装饰"的分水岭。
🔥【免费下载链接】Front-End-Checklist
🗂 The essential checklist for modern web development, for humans and AI agents
相关推荐
Front-End-Checklist 单元测试实战指南:从 Vitest 配置到 CI 覆盖率的完整落地
Front End Checklist 单元测试实战指南:从 Vitest 配置到 CI 覆盖率的完整落地 单元测试是 Front End Checklist
Front-End-Checklist 项目实战:用测试覆盖率阈值守住代码质量底线(Jest / Vitest / CI 全配置指南)
Front End Checklist 项目实战:用测试覆盖率阈值守住代码质量底线(Jest / Vitest / CI 全配置指南) 本篇指南以 Front
ESP-IDF v5.4.1 开发环境搭建:从 clone 到编译通过的实操手册
ESP IDF v5.4.1 开发环境搭建:从 clone 到编译通过的实操手册 电池供电的温湿度节点,每次唤醒上报一次数据,整条链路就落在 ESP IDF 这
物联网嵌入式
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考