Front-End-Checklist 单元测试实战指南:从 Vitest 配置到 Jest 全覆盖体系
2026/9/20 9:23:33 网站建设 项目流程

🔥【免费下载链接】Front-End-Checklist

🗂 The essential checklist for modern web development, for humans and AI agents

项目地址:https://gitcode.com/gh_mirrors/fr/Front-End-Checklist
点击查看免费下载

单元测试是 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,并做了三件关键事情:

  1. jsdom 测试环境+@testing-library/jest-dom断言扩展(见 apps/web/jest.setup.cjs);
  2. 模块别名映射^@/(.*)$ → <rootDir>/$1,以及把@repo/*workspace 包指向对应src源码,保证测试直接覆盖源码而非构建产物;
  3. 排除 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 类名合并优先级)、formatDateformatTechTermdebounce四个纯函数。其中防抖测试非常值得学习,它用假定时器精确验证"时间边界"这一典型边界条件:

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/reactrenderscreenfireEventact,与规则文档推荐的 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-ThenBDD 风格用行为描述替代实现描述
Test doubles外部依赖Mocks、stubs、spies
Parameterized多组输入it.each([...])
SnapshotUI / 大输出expect().toMatchSnapshot()

其中参数化测试(it.each)尤其适合"同一逻辑、多组数据"的场景,例如校验函数可以一次性列出合法/非法/边界值若干组输入,显著降低重复代码。

覆盖率脚本与门槛配置

// package.json { "scripts": { "test": "vitest", "test:coverage": "vitest run --coverage", "test:watch": "vitest --watch" } }

覆盖率数字本身不是目的。原文档特别强调:"高覆盖率不等于高质量。与其追逐任意的覆盖率数字,不如聚焦关键路径与边界情况;测试行为,而非实现细节。" 本仓库的设计也印证了这一点:jest.base.cjs将 branches 门槛(70%)有意设置得低于其他维度(80%),正是因为分支覆盖在某些场景下投入产出比低,硬性拉高反而诱导开发者写"为覆盖而覆盖"的用例。

测试清单:交付前的自查项

在合并代码前,逐项核对:

  1. ✅ 测试 happy path(期望输入)
  2. ✅ 测试边界情况(空值、null、边界值)
  3. ✅ 测试错误条件(非法输入)
  4. ✅ 测试异步行为(loading、成功、失败状态)
  5. ✅ 测试可访问性(ARIA 属性、roles)
  6. ✅ 测试用户交互(click、type、submit)
  7. ✅ 在每次 PR 的 CI 中运行测试

标准与验证

执行标准:本规则要求测试/监控策略在交付工作流中持续生效,而不是"文档写到了就算完成"。落实时对照两条外部标准自检:对照 Playwright 文档核对实现(涉及 E2E 部分时),对照 Testing Library Guiding Principles 核对组件测试的断言方式(是否以用户视角、是否测试行为而非实现)。

自动化验证

  • 至少为改动影响的一条主路径和一个边界情况编写测试;
  • 在可用的浏览器或 CI 工具中验证修复,确认测试确实在真实环境中运行。

人工验证

  • 确认规则在最终渲染输出或运行时行为中生效;
  • 复查共享抽象层,确保修复在一致的位置统一应用,而不是只修了单个调用点。

小结

围绕 skills/unit-tests/references/rule.md,完整的单元测试落地路径可以归纳为五步:搭环境(Vitest 或本仓库的 Jest 统一工厂配置,含 setup 与 matchMedia 等浏览器 API 补齐)→排优先级(按出错代价从支付/表单/认证向下排序)→分层覆盖(纯函数 → 组件 → Hooks → 异步)→定门槛(80% 覆盖率阈值,低分支门槛取舍)→进 CItest:ci/test:coverage:check脚本 + 覆盖率门禁)。记住那句贯穿始终的忠告:高覆盖率不等于高质量,测试的关键路径与边界情况,测试行为而非实现细节——这才是让单元测试真正成为"可靠性保障"而非"KPI 装饰"的分水岭。

🔥【免费下载链接】Front-End-Checklist

🗂 The essential checklist for modern web development, for humans and AI agents

项目地址:https://gitcode.com/gh_mirrors/fr/Front-End-Checklist
点击查看免费下载
上一篇:如何构建Quartz内容审核工作流:多人协作与权限管理完整指南
下一篇:ZFS-inplace-rebalancing进度监控与日志分析完全指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询