资讯动态

Front-End-Checklist 单元测试实战指南:从 Vitest 配置到 Jest 全覆盖体系

发布时间:2026/9/20 9:23:35 来源:尧图企业网站定制
【免费下载链接】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 mockinclude测试文件匹配模式默认覆盖*.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终端、lcovCI 上报、html本地可视化三种报告。同时提供createPackageJestConfig()工厂函数各 package 只需一行配置即可继承统一规范。例如 packages/utils/jest.config.jsconst { 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/TextDecodermocknext/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 是同款思路的真实落地它测试了cntailwind 类名合并优先级、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 typebutton 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(ButtonClick me/Button) expect(screen.getByRole(button)).toHaveTextContent(Click me) }) it(calls onClick when clicked, async () { const user userEvent.setup() const handleClick vi.fn() render(ButtonClick me/Button) await user.click(screen.getByRole(button)) expect(handleClick).toHaveBeenCalledTimes(1) }) it(is disabled when disabled prop is true, () { render(ButtonClick me/Button) expect(screen.getByRole(button)).toBeDisabled() }) it(shows loading state, () { render(ButtonSubmit/Button) const button screen.getByRole(button) expect(button).toHaveTextContent(Loading...) expect(button).toHaveAttribute(aria-busy, true) expect(button).toBeDisabled() }) it(applies variant class, () { render(ButtonCancel/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(ButtonClick 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()实践原则模块级 mockvi.mock用于替换外部服务分析 SDK、认证客户端等让测试聚焦被测单元自身逻辑环境变量vi.stubEnv让 CI 与本地环境行为一致假定时器vi.useFakeTimers让 debounce、轮询、延迟等时间敏感逻辑可瞬时推进、稳定断言Spyvi.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结构并遵循 AAAArrange-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/checkoutv4 - uses: pnpm/action-setupv2 with: version: 8 - uses: actions/setup-nodev4 with: node-version: 20 cache: pnpm - run: pnpm install - run: pnpm test:coverage - name: Upload coverage uses: codecov/codecov-actionv4 with: files: coverage/coverage-final.json本仓库的 Web 应用在 apps/web/package.json 中提供了对应的本地脚本矩阵含义与 CI 各步骤一一对应{ scripts: { test: jest --watchmanfalse --passWithNoTests, test:ci: jest --watchmanfalse --ci --maxWorkers1 --passWithNoTests, test:coverage: jest --watchmanfalse --coverage --passWithNoTests, test:coverage:check: jest --watchmanfalse --coverage --coverageReporterstext-summary --coverageReportersjson-summary --passWithNoTests, test:related: jest --watchmanfalse --findRelatedTests --passWithNoTests } }test本地开发模式watch 默认关闭适合直接运行test:ciCI 专用--ci关闭交互、--maxWorkers1控制资源占用保证结果可复现test:coverage本地查看覆盖率详情test:coverage:check以text-summaryjson-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、spiesParameterized多组输入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%正是因为分支覆盖在某些场景下投入产出比低硬性拉高反而诱导开发者写为覆盖而覆盖的用例。测试清单交付前的自查项在合并代码前逐项核对✅ 测试 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% 覆盖率阈值低分支门槛取舍→进 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点击查看免费下载相关推荐Front-End-Checklist 单元测试实战指南从 Vitest 配置到 CI 覆盖率的完整落地Front End Checklist 单元测试实战指南从 Vitest 配置到 CI 覆盖率的完整落地 单元测试是 Front End ChecklistFront-End-Checklist 项目实战用测试覆盖率阈值守住代码质量底线Jest / Vitest / CI 全配置指南Front End Checklist 项目实战用测试覆盖率阈值守住代码质量底线Jest / Vitest / CI 全配置指南 本篇指南以 FrontESP-IDF v5.4.1 开发环境搭建从 clone 到编译通过的实操手册ESP IDF v5.4.1 开发环境搭建从 clone 到编译通过的实操手册 电池供电的温湿度节点每次唤醒上报一次数据整条链路就落在 ESP IDF 这物联网嵌入式上一篇如何构建Quartz内容审核工作流多人协作与权限管理完整指南下一篇ZFS-inplace-rebalancing进度监控与日志分析完全指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价