楼层: 首页/ 软件技术/ 前端进阶/ 前端测试与组件文档:从 "不敢改" 到 "随手改"
测

前端测试与组件文档:从 "不敢改" 到 "随手改"

Frontend Testing · Vitest / Testing Library / MSW / Playwright / Storybook

前端代码有一个特殊性质:类型系统能保证"传参对了",但保证不了"用户点上去会发生什么"。一个按钮的 onClick 绑错了函数、一个 loading 状态忘了置回 false、一个表单在提交失败时把用户输入清空了——这些 TypeScript 一句话都管不了,只有测试能管。这一章按"测试金字塔"从上到下讲一遍:单元测试(Vitest)→ 组件测试(Testing Library + MSW)→ 端到端(Playwright)→ 组件文档(Storybook + 视觉回归 + a11y),重点讲每层的成本与收益,以及那些"写了测试却越写越累"的原因。

测试金字塔在前端的具体形态

金字塔这个比喻大家都听过,但前端的金字塔和"后端的金字塔"配比并不一样。原因是前端的核心风险集中在"用户交互与渲染",而这部分恰好是组件测试最擅长的。

层级测什么速度建议占比典型工具与注意点
单元纯函数:格式化、校验、状态计算、reducer毫秒级约 60%Vitest。不需要 jsdom,最快最稳,性价比最高。别测实现细节,只测输入输出。
组件一个组件或一小块交互:渲染、点击、表单校验、loading/error 态几十到几百毫秒约 30%Vitest + Testing Library + jsdom + MSW。前端最有价值的一层,因为它覆盖了"用户操作 → 界面变化"这条链路。
端到端真实浏览器里的完整流程:登录、下单、支付秒到分钟级约 10%Playwright。只覆盖关键主流程(3~10 条),多了会变成团队的负担。
视觉回归UI 像素级变化(样式错乱、意外改动)秒级 + 人工确认关键页面Playwright 截图对比 / Chromatic。只覆盖稳定的关键页面,否则截图天天变,没人看。

论为什么组件测试是前端性价比最高的一层

① 它测的正是风险所在:前端绝大多数线上 bug 不是"算法算错了",而是"交互没按预期响应"——点了没反应、加载态卡住、错误提示不出现、表单校验漏了。这些都是组件测试的靶心。

② 它的速度足够快:一个组件测试通常几百毫秒,一个文件里几十个用例几秒钟跑完,可以挂在 watch 模式下随写随跑。反馈速度决定了你会不会真的用它。

③ 它比 E2E 稳定得多:不需要真实浏览器、不需要后端服务、不受网络与并发影响。E2E 的 flaky(偶发失败)是团队信任崩塌的起点——一旦大家习惯了"重跑一次就好了",测试就失去了意义。

④ 所以配比建议是"组件测试写厚,E2E 写薄",这与后端"单元厚、集成薄"的思路一致,只是前端把"单元"的权重分了一部分给"组件"。

Vitest 配置与 jsdom 环境

Vitest 是当前 Vite 生态下的默认选择,它复用了 Vite 的配置和转换管线,所以和你的项目配置天然一致(不像过去 Jest 需要单独配一遍 moduleNameMapper、transform)。

完整可用的 Vitest 配置:环境、setup、覆盖率

// ================= vitest.config.ts ================= import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import path from "node:path"; export default defineConfig({ plugins: [react()], resolve: { alias: { "@": path.resolve(__dirname, "./src") }, }, test: { // environment:纯函数测试用 node 更快;涉及 DOM 的用 jsdom // 也可以按文件顶部注释 @vitest-environment jsdom 单独覆盖 environment: "jsdom", // setup 文件:全局的 jest-dom 匹配器、MSW 生命周期、清理逻辑放这里 setupFiles: ["./src/test/setup.ts"], globals: true, // 免去每个文件 import { describe, it, expect } // 只收集 src 下的测试,避免把 node_modules 里的示例也算进来 include: ["src/**/*.{test,spec}.{ts,tsx}"], exclude: ["node_modules", "e2e/**"], // e2e 交给 Playwright 跑 // 组件测试里的定时器与异步要小心,这几个开关很关键 restoreMocks: true, // 每个用例前自动恢复被 spy 的原实现,避免互相污染 clearMocks: true, coverage: { provider: "v8", reporter: ["text", "html", "lcov"], // ★ 最重要的一行:把纯类型文件、入口文件排除,否则覆盖率数字失真 exclude: [ "**/*.d.ts", "**/*.config.*", "src/test/**", "src/**/index.ts", // barrel 文件通常只是 re-export "src/mocks/**", ], thresholds: { // 只对"关键目录"设阈值,别全局一刀切 "src/utils/**": { lines: 80, functions: 80 }, "src/features/checkout/**": { lines: 70 }, }, }, }, }); // ================= src/test/setup.ts ================= // 引入 jest-dom 的自定义匹配器:toBeInTheDocument / toBeDisabled / toHaveValue ... import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach, beforeAll, afterAll, vi } from "vitest"; import { server } from "./msw-server"; // 每个用例后清理 DOM 与未处理的副作用 afterEach(() => cleanup()); // 有些组件依赖这些浏览器 API,jsdom 里没有,需要打桩 beforeAll(() => { // 比如图表库、滚动监听常用到;不打桩会报 is not a function window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })) as any; // ResizeObserver 同理 window.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as any; }); // ================= MSW:拦截网络请求(见后文详解)================= beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); // ================= 运行命令 ================= # package.json "test": "vitest", // watch 模式,开发时用 "test:run": "vitest run", // CI 里用,跑完即退出 "test:cov": "vitest run --coverage", // 出覆盖率报告

Testing Library:以用户视角测试

Testing Library 的核心哲学是一句话:"你的测试越像用户使用软件的方式,它给你的信心就越多。"反过来说,测实现细节的测试,会在重构时全部变红,而且它变红时通常不是功能坏了。这就是"测实现细节导致重构即碎测试"的根源。

查询方式用途优先级与说明
getByRole按可访问性角色查(button / textbox / heading / link)首选。它同时保证了可访问性正确(用户和屏幕阅读器都靠这个找到元素)。写法:getByRole("button", { name: "提交" })。
getByLabelText按表单标签查输入框表单首选。强制你的 label 正确关联了 input(htmlFor),顺手保证了可用性。
getByPlaceholderText / getByText按占位符 / 文本内容查可用,但要注意占位符不是标签(屏幕阅读器不认),文本容易被复制文案改坏。
getByAltText / getByTitle按图片替代文本 / title 属性查图片场景可用。
getByTestId按 data-testid 属性查最后手段。它不检查任何用户可见的语义,用户和辅助技术都看不到这个属性。只有在"实在没有可访问的标识"时才用(比如某个纯装饰性容器)。

组件测试完整示例:从查询方式到 userEvent 到 findBy

// ================= src/features/comment/CommentForm.test.tsx ================= import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import CommentForm from "./CommentForm"; describe("CommentForm", () => { it("输入内容并提交后,应该显示成功提示", async () => { // ★ 关键:用 userEvent 而不是 fireEvent // userEvent 会模拟真实的交互序列:focus → 输入(逐字符触发事件)→ blur → click // 所以键盘事件、焦点变化这些"真实用户会触发"的行为都会被覆盖 const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render(<CommentForm onSubmit={onSubmit} />); // ✅ 用 role + name 查询:既稳又能保证可访问性 // 如果这里查不到,说明 label 没关联好 —— 测试顺手帮你发现了 a11y 问题 const textarea = screen.getByRole("textbox", { name: /评论内容/ }); await user.type(textarea, "这个商品很好用"); // userEvent.click 会等待所有由此触发的异步更新,不需要手动 act() await user.click(screen.getByRole("button", { name: "发表评论" })); // 断言:找的是"用户能看到的结果",不是内部 state const ok = await screen.findByText("评论成功"); // findBy = 自动重试直到超时 expect(ok).toBeInTheDocument(); expect(onSubmit).toHaveBeenCalledWith({ content: "这个商品很好用" }); }); it("内容为空时不应该提交,并提示错误", async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(<CommentForm onSubmit={onSubmit} />); // 直接点提交,不输入任何内容 await user.click(screen.getByRole("button", { name: "发表评论" })); const err = await screen.findByRole("alert"); // role=alert 是错误提示的标准语义 expect(err).toHaveTextContent("请输入内容"); expect(onSubmit).not.toHaveBeenCalled(); }); it("提交过程中按钮应该被禁用,避免重复提交", async () => { const user = userEvent.setup(); // 让提交"卡住",以便观察中间态 let resolve!: () => void; const onSubmit = vi.fn().mockImplementation(() => new Promise<void>(r => { resolve = r; })); render(<CommentForm onSubmit={onSubmit} />); await user.type(screen.getByRole("textbox", { name: /评论内容/ }), "内容"); await user.click(screen.getByRole("button", { name: "发表评论" })); // 中间态断言:按钮禁用 + 文案变化 await waitFor(() => expect(screen.getByRole("button")).toBeDisabled()); // 放行,清理,避免"未处理的 Promise 更新"警告 resolve(); await waitFor(() => expect(screen.getByRole("button")).toBeEnabled()); }); }); // ================= getBy / findBy / queryBy 的区别(高频考点)================= // getByX :元素必须存在,否则立刻抛错。用于"确定存在"的断言 // queryByX :元素不存在时返回 null 而不抛错。用于断言"不存在" // findByX :异步版本,会不断重试直到超时(默认 1000ms)。用于"等待出现" // findByRole("alert") 是等错误提示出现的标准写法 expect(screen.queryByRole("alert")).not.toBeInTheDocument(); // 断言不存在
为什么 userEvent 比 fireEvent 好,以及 waitFor 的正确用法

关于 userEvent:fireEvent.click(btn) 只是直接派发一个 click 事件,它不会先 focus、不会处理 disabled、不会模拟输入法。而 userEvent.click(btn) 会按真实顺序:检查元素是否可交互 → focus → 派发事件 → 处理副作用,并且会等待由它触发的异步更新。结果是:用 fireEvent 写的测试常常"通过了但实际点不动"(比如元素被 disabled 了、被遮挡了),而 userEvent 会直接失败。关于 waitFor:它用于等待"某个断言最终成立",只在必要时用。常见误用是"用 waitFor 包住一堆断言"——那会让失败时的报错变得很难懂。优先用 findBy*(它内部就是 waitFor + getBy,专门用于等待元素出现),只有在"断言的内容不是元素存在性"(比如某个值变成 2)时才用 waitFor。另外,绝对不要在测试里用 setTimeout 或固定 sleep 等异步,这是 flaky 的头号来源。

Mock:vi.mock 与 msw 的分工

前端要 Mock 的东西分两类,这两类要用完全不同的工具,混用会写出很脆的测试:一类是"模块里的函数"(比如一个埋点上报函数、一个时间格式化工具),另一类是"网络请求"。

要 Mock 的目标工具用法要点
模块级函数(埋点、工具、第三方 SDK)vi.mock("模块路径")会被提升到文件顶部(hoisted),所以不能引用外部变量(除非用 vi.hoisted)。适合"我不关心这个函数内部,只想断言它被调用了"。
单个函数的返回值vi.spyOn(obj, "method")比 vi.mock 更轻,保留原实现或只替换一次。用完记得 restore(配 restoreMocks: true 更省心)。
HTTP 请求msw(Mock Service Worker)推荐做法。它拦截的是真实的 fetch/XHR,在测试里和浏览器里(浏览器用 Service Worker)都能用。组件代码完全不用改,这是它相比 mock fetch 最大的优势。
定时器vi.useFakeTimers()用于测防抖/节流/倒计时。注意:用了 fake timers 之后 findBy* / waitFor 会失效(因为它们依赖真实时间),要用 vi.advanceTimersByTimeAsync() 手动推进。
环境变量 / 全局对象vi.stubGlobal / vi.stubEnv比直接赋值 window 更干净,用例结束会自动还原。

MSW 的正确用法:服务端请求由 handler 决定,组件代码零改动

// ================= src/test/msw-server.ts ================= import { setupServer } from "msw/node"; import { handlers } from "./handlers"; // 在 Node 环境(Vitest)里启动拦截;同名 handlers 也能给浏览器 dev 环境用 export const server = setupServer(...handlers); // ================= src/test/handlers.ts ================= import { http, HttpResponse } from "msw"; export const handlers = [ // 成功场景 http.get("/api/products", () => HttpResponse.json([ { id: "1", name: "键盘", price: 299 }, { id: "2", name: "鼠标", price: 99 }, ]) ), // 动态响应:根据请求参数决定返回内容 http.get("/api/products/:id", ({ params }) => HttpResponse.json({ id: params.id, name: "键盘", price: 299 }) ), // 错误场景:用一组"开关",让测试可以切换成功/失败 // 这是 MSW 最实用的技巧之一:把场景控制在测试文件里 ]; // ================= 测试文件里按场景覆盖 handler ================= it("接口返回 500 时,应该显示错误提示", async () => { // server.use 只在当前用例内生效,afterEach 的 resetHandlers 会自动清理 server.use( http.get("/api/products", () => HttpResponse.json({ message: "服务器开小差了" }, { status: 500 }) ) ); render(<ProductList />); const err = await screen.findByRole("alert"); expect(err).toHaveTextContent("服务器开小差了"); }); it("接口很慢时,应该先显示加载态", async () => { // 用 delay 模拟慢接口,注意要小于测试超时时间 server.use( http.get("/api/products", async () => { await delay(100); return HttpResponse.json([]); }) ); render(<ProductList />); expect(screen.getByRole("status")).toHaveTextContent("加载中"); await screen.findByText("暂无数据"); }); // ================= vi.mock 的正确用法(模块级 Mock)================= // ⚠️ vi.mock 会被提升到文件顶部,所以下面这样写会因为"变量未初始化"报错: // const spy = vi.fn(); // vi.mock("@/lib/analytics", () => ({ track: spy })); // ❌ 报错 // ✅ 正确写法之一:用 vi.hoisted 把变量一起提升 const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() })); vi.mock("@/lib/analytics", () => ({ track: trackMock, trackPageView: vi.fn(), })); it("点击购买时应该上报埋点", async () => { const user = userEvent.setup(); render(<BuyButton productId="1" />); await user.click(screen.getByRole("button", { name: "立即购买" })); // 断言"上报了这个事件",而不是断言"按钮的 class 变了" expect(trackMock).toHaveBeenCalledWith("buy_click", { productId: "1" }); }); // ✅ 只替换一次返回值,其余保持原实现:用 spyOn 更轻 it("时间格式化在跨年时应该带上年份", () => { const spy = vi.spyOn(Date, "now").mockReturnValue(new Date("2027-01-01").getTime()); expect(formatDate(new Date("2026-12-31"))).toContain("2026"); spy.mockRestore(); });

论为什么应该 Mock 网络而不是 Mock fetch 函数

① 用 vi.spyOn(global, 'fetch') 的问题:你 Mock 掉了整个 fetch,于是"URL 拼错了""请求方法用错了""请求头漏了"这些错误测不出来——因为请求根本没发出去。而这类错误恰恰是前端最高频的 bug。

② MSW 的做法:它拦截的是真实的网络层,你的代码还是照常发 fetch,只是响应由 handler 决定。所以 URL、方法、请求体都会被真实校验,写错了 handler 匹配不上,测试直接失败(配合 onUnhandledRequest: "error" 更严格)。

③ 额外收益:同一份 handler 可以给浏览器开发环境用。本地开发时后端还没写好?把 handlers 挂到 Service Worker 上,前端就能独立开发,而且这些 handler 和测试里的是同一份,不会出现"mock 数据和真实接口不一致"的经典问题。

Playwright:端到端与视觉回归

E2E 测试的价值在于它验证真实浏览器里的完整链路——真实路由、真实渲染、真实后端(或真实的前后端契约)。它的成本也在于此:慢、环境依赖重、容易 flaky。所以策略是"少而精":只覆盖关键业务主流程。

Playwright 的写法:定位、断言、视觉回归、trace

// ================= playwright.config.ts ================= import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", fullyParallel: true, // CI 上禁止 test.only(避免误提交导致只跑一个用例) forbidOnly: !!process.env.CI, // ★ 重试策略:CI 上给 2 次。但请务必关注重试率—— // 重试率高说明测试本身不稳定,应该修测试而不是依赖重试 retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, // 并行度过高会互相干扰 reporter: [["html"], ["list"]], use: { baseURL: "http://localhost:3000", // trace:失败时保留完整的执行轨迹,可以用 Trace Viewer 回放 trace: "on-first-retry", screenshot: "only-on-failure", video: "retain-on-failure", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] } }, { name: "mobile", use: { ...devices["iPhone 15"] } }, // 关键页面才跑移动端 ], webServer: { command: "npm run start", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, }, }); // ================= e2e/checkout.spec.ts ================= import { test, expect } from "@playwright/test"; test("完整下单流程:登录 → 加购 → 提交订单", async ({ page }) => { // 用 getByRole 而不是 CSS 选择器:语义化定位,重构样式不会碎 await page.goto("/login"); await page.getByLabel("邮箱").fill("buyer@example.com"); await page.getByLabel("密码").fill("secret123"); await page.getByRole("button", { name: "登录" }).click(); // ★ 永远不要用 waitForTimeout(3000) —— 这是 flaky 的头号来源 // 正确做法:等待"期望出现的内容",Playwright 会自动重试(默认超时 5s) await expect(page.getByRole("heading", { name: "我的订单" })).toBeVisible(); await page.getByRole("link", { name: "机械键盘" }).click(); await page.getByRole("button", { name: "加入购物车" }).click(); // 断言"网络请求发生了"(比断言 UI 更接近本质) await expect(page.getByRole("status")).toHaveText(/已加入/); // 等待接口响应,确保后续操作基于最新数据 await page.waitForResponse(r => r.url().includes("/api/cart") && r.status() === 200); await page.getByRole("link", { name: /购物车/ }).click(); await page.getByRole("button", { name: "结算" }).click(); await expect(page.getByRole("heading", { name: "订单提交成功" })).toBeVisible(); }); // ================= 视觉回归:只用于稳定的关键页面 ================= test("首页视觉不应发生意外变化", async ({ page }) => { await page.goto("/"); // 关键:把动态内容遮掉,否则每次截图都不一样 // 比如时间、随机推荐、用户头像,用 mask 参数屏蔽 await expect(page).toHaveScreenshot("home.png", { mask: [ page.locator("[data-testid=clock]"), page.locator(".recommend-list"), ], maxDiffPixelRatio: 0.01, // 容忍 1% 像素差异,避免抗锯齿导致误报 }); }); // ================= 调试:失败后用 Trace Viewer 回放 ================= // npx playwright test --trace on // npx playwright show-trace test-results/xxx/trace.zip // Trace Viewer 能看到:每一步操作前后的 DOM 快照、网络请求、控制台日志、 // 精确的失败位置 —— 这是排查 flaky 测试最有效的工具 // ================= 组件级 E2E:Playwright 也能测单个组件 ================= // 适合"需要真实浏览器行为(如 CSS 布局、拖拽)但不想起整个应用"的场景 test("拖拽排序组件在真实浏览器里可用", async ({ page }) => { await page.setContent(`<div id="app"></div>`); // 配合 vite 的组件预览或独立入口,把组件挂载到 #app 上再测 });
E2E 变成团队负担的两个原因

原因一:用固定 sleep 等异步。await page.waitForTimeout(3000) 是 E2E 里最该被禁用的 API。它的问题不是"慢",而是"不可靠"——本地 3 秒够了,CI 机器慢一点就不够;改成 5 秒,整体时间涨一倍,而且下个月数据量大了还是不够。正确写法是等待"期望的状态出现":expect(...).toBeVisible()(自动重试到超时)、waitForResponse()(等接口回来)、waitForLoadState("networkidle")(等网络空闲)。Playwright 的自动等待机制已经把 90% 的等待场景覆盖了,手动 sleep 基本只有一个用途:调试时暂停观察。

原因二:用例太多、覆盖太细。把表单的每个字段校验都写成 E2E,结果每次改文案都要改十几条 E2E。E2E 应该只覆盖"跨多个页面、跨前后端的关键业务链路"(登录、下单、支付、核心查询),其余的交给组件测试。数量上一条经验规则:一个产品的 E2E 用例控制在 5~20 条,超过这个量就要怀疑是不是把组件测试的活派给了 E2E。另外,只要测试出现"重跑就过",就要当 bug 修——flaky 测试最大的危害是让团队不再相信红灯。

Storybook:组件文档、视觉测试与 a11y

Storybook 的价值经常被低估——它表面上是"组件预览器",实际解决的是"团队怎么共享组件"和"怎么把所有状态都看一遍"这两件事。一个组件有 loading / empty / error / long-text / 无权限 五种状态,写 Story 逼着你把它们都列出来,这个过程本身就能发现设计上的遗漏。

CSF3 写法:一个组件,多个 Story,argTypes 做控制面板

// ================= src/components/ProductCard.stories.tsx ================= import type { Meta, StoryObj } from "@storybook/react"; import { ProductCard } from "./ProductCard"; // CSF3:默认导出描述"这个组件的元信息",具名导出每个状态 const meta = { title: "电商/ProductCard", component: ProductCard, tags: ["autodocs"], // 自动生成文档页(从 props 类型与描述来) // argTypes 决定右侧控制面板怎么展示,也用于自动生成文档表格 argTypes: { price: { control: { type: "number", min: 0, step: 1 }, description: "价格(元)" }, status: { control: { type: "select" }, options: ["onSale", "soldOut", "new"], description: "商品状态标签", }, onAddToCart: { action: "addToCart" }, // 点击时在 Actions 面板打印 }, // 装饰器:给所有 story 套一层壳(比如主题 Provider、路由 Provider) decorators: [ (Story) => ( <div style={{ padding: 24 }}> <Story /> </div> ), ], } satisfies Meta<typeof ProductCard>; export default meta; type Story = StoryObj<typeof meta>; // ---------- 每个 Story 就是"一种状态" ---------- export const 默认: Story = { args: { id: "1", name: "机械键盘", price: 299, status: "new" }, }; export const 促销中: Story = { args: { ...默认.args, status: "onSale", price: 199 }, }; export const 已售罄: Story = { args: { ...默认.args, status: "soldOut" }, // 交互测试:在 Storybook 里直接跑断言(play 函数) play: async ({ canvasElement }) => { const canvas = await within(canvasElement).findByRole("button", { name: "加入购物车" }); expect(canvas).toBeDisabled(); // 售罄时按钮必须禁用 }, }; // 超长文本:这类边界状态最容易被漏掉,Storybook 逼你正面处理 export const 超长名称: Story = { args: { ...默认.args, name: "这是一个非常非常长的商品名称用来测试布局是否会被撑破并且需要换行处理" }, }; // 加载态:注意 loading 时不应该显示价格(避免闪出错误信息) export const 加载中: Story = { args: { id: "1", name: "", price: 0, loading: true }, }; // ================= 与 MSW 联合做"交互态"(关键技巧)================= // .storybook/preview.ts:让所有 Story 都走 MSW 拦截网络 import { initialize, mswLoader } from "msw-storybook-addon"; initialize({ onUnhandledRequest: "bypass" }); const preview = { loaders: [mswLoader], // 自动为每个 story 建立/清理 handlers }; export default preview; // 然后在某个 Story 上指定它需要的接口行为: export const 接口失败: Story = { parameters: { msw: { handlers: [ http.get("/api/products/1", () => HttpResponse.json({ message: "服务不可用" }, { status: 503 })), ], }, }, }; // ================= Chromatic:把每个 Story 变成一次视觉测试 ================= # 安装后接入 CI:每一个 Story 都会截图,与基线对比 npx chromatic --project-token=$CHROMATIC_TOKEN --exit-zero-on-changes # 效果:改了 CSS 之后,如果某个 Story 的渲染发生变化,PR 上会出现视觉 diff # 由人来确认"这个变化是预期的吗?" —— 预期则更新基线,非预期则说明改坏了 # 这一步能在"样式意外影响其他组件"这类问题上提供极强保障 // ================= a11y 自动检查 ================= // 方案一:在组件测试里用 jest-axe 检查可访问性 import { axe, toHaveNoViolations } from "jest-axe"; expect.extend(toHaveNoViolations); it("表单不应该有无障碍问题", async () => { const { container } = render(<CommentForm onSubmit={vi.fn()} />); const results = await axe(container); expect(results).toHaveNoViolations(); // 它会检查:图片有没有 alt、按钮有没有可访问名称、label 有没有关联、 // 颜色对比度是否达标、heading 层级是否跳跃…… }); // 方案二:在 E2E 里做全页扫描(能覆盖真实渲染结果) import AxeBuilder from "@axe-core/playwright"; test("首页应该通过无障碍扫描", async ({ page }) => { await page.goto("/"); const results = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa"]) .analyze(); expect(results.violations).toEqual([]); }); // 注意:自动检查只能覆盖约 30%~40% 的无障碍问题 // 键盘可达性、焦点顺序、屏幕阅读器朗读顺序这些必须人工验证
工具它保护你什么它的边界
Storybook组件所有状态可预览、可交互、可作为团队共享的"活文档"它本身不做断言,需要配合 play 函数、Chromatic 或 a11y 插件
Chromatic(或截图对比)样式回归:改了 CSS 影响到的所有组件都会被标出来动态内容必须 mask,否则天天报 diff;需要人工确认,有维护成本
jest-axe / axe-core自动化的 a11y 检查:alt、label 关联、对比度、heading 层级只能发现约三到四成问题;键盘操作、焦点管理、朗读顺序测不出来
关于测试的三个反直觉结论

一、测实现细节的测试比没有测试更糟。它给你虚假的安全感,而且重构时成片变红,逼着大家去改测试——改着改着,测试就变成了"跟着实现走"的注释,再也没有发现问题。判断标准很简单:如果我把组件内部重写一遍但用户可见行为不变,测试应该全绿。如果它红了,说明它测的是实现。

二、覆盖率高的项目不一定质量好。100% 覆盖率可以是"每个函数都被调用过一次,但没有任何有意义的断言"。真正有价值的是"关键路径 + 错误路径都覆盖了"(表单失败、接口 500、网络超时、权限不足),这些往往只占代码的 20%,但对应 80% 的线上问题。

三、测试写得多不等于写得好,维护成本要算进去。一条 E2E 用例的维护成本大约是组件测试的十倍(环境、数据、时序、UI 变动)。所以选型的第一原则是"用最便宜的那一层覆盖这个风险"——能单元测的别写组件测,能组件测的别写 E2E。

记
本章小结

① 配比:单元约 60%、组件约 30%、E2E 约 10%;组件测试是前端性价比最高的一层。

② Vitest 复用 Vite 配置,用 environment: jsdom + setupFiles;覆盖率要排除类型文件与 barrel,只对关键目录设阈值。

③ 查询优先级:getByRole > getByLabelText > 文本类 > getByTestId(最后手段)。

④ 用 userEvent 而不是 fireEvent;用 findBy* 等待元素出现,不要用 sleep。

⑤ Mock 网络用 MSW(能校验 URL/方法/请求体,且可复用到浏览器开发环境),模块级 Mock 用 vi.mock(注意提升问题,配合 vi.hoisted)。

⑥ Playwright 定位用 getByRole;禁用 waitForTimeout,改为等待状态或响应;失败时用 Trace Viewer 排查。

⑦ 视觉回归只覆盖稳定的关键页面,动态内容必须 mask,并设置合理的像素容差。

⑧ Storybook 的价值在于"逼你把所有状态都列出来",配合 play 函数、Chromatic、axe 形成完整的组件质量闭环。

⑨ a11y 自动检查只能覆盖约三四成问题,键盘与朗读顺序必须人工验证。

⑩ 检验测试好坏的标准:重写内部实现但用户可见行为不变,测试应该全绿。

本章自测

自测 · 前端测试与组件文档

1.(判断题)我用 getByTestId("submit-btn") 查按钮,能用但总觉得不对劲。应该用什么?

看答案

答案:应该用 getByRole("button", { name: "提交" })。理由:① data-testid 对用户和辅助技术都不可见,用它查询等于"完全绕过了可访问性";② 用 role + name 查询,测试会顺便验证"这个按钮在无障碍树里是可识别的、有正确的名称"——它把 a11y 检查融进了每次测试;③ role 查询更抗重构(改 class、改包裹层都不影响)。什么时候才用 testid:实在没有可访问语义的纯容器、或者要定位一个没有文本的装饰性元素。用的话也建议配上 eslint-plugin-testing-library 的规则做约束。

2.(工程题)我用 vi.spyOn(global, "fetch").mockResolvedValue(...) 测组件,测试通过了,但线上还是出现了"请求地址写错"的 bug。为什么?

看答案

答案:因为你把 fetch 整个替换掉了,请求根本没有真正发出,所以 URL、HTTP 方法、请求体、请求头这些都不被校验——组件里把 /api/product 写成 /api/products,测试照样绿。解法:改用 MSW,它拦截真实网络层,你的代码照常发 fetch,只有响应由 handler 决定;URL 写错了 handler 匹配不上,配合 onUnhandledRequest: "error" 会直接报错。额外好处:同一份 handler 可以挂到浏览器的 Service Worker 上,给本地开发用,避免"mock 数据与真实接口不一致"。

3.(排查题)一个 E2E 用例在本地永远通过,在 CI 上大约三成概率失败,报"元素不可见"。列出你的排查与修复步骤。

看答案

答案:典型的 flaky。排查顺序:① 看是不是用了固定 sleep——CI 机器慢,3 秒不够就必然失败,改为 expect(...).toBeVisible() 或 waitForResponse();② 看是不是依赖了网络或第三方资源——接口慢导致渲染晚,应该 mock 或等待特定响应;③ 看是不是并行导致的资源竞争——多个用例抢同一个测试账号/数据,改 workers 或让数据按用例隔离;④ 看动画与 CSS——元素在过渡中不可见,Playwright 有 animations: "disabled" 选项;⑤ 用 Trace Viewer 回放失败现场(--trace on),能看到失败前的 DOM 快照、网络与控制台,通常一眼看出问题。修复后要持续关注重试率——"重跑就过"应该被当作 bug 修,而不是用 retries 掩盖。

4.(设计题)团队新加了个很复杂的表单组件,你打算怎么保证它的质量?

看答案

答案:分三层做,按成本从低到高:① 单元测试(Vitest)——把校验规则、格式化、状态计算这些纯函数抽出来单独测,这层最快最稳,覆盖大部分逻辑分支(包括边界:空值、超长、特殊字符)。② 组件测试(Testing Library)——测"用户可见行为":填写、提交、校验错误提示、提交中按钮禁用、接口 500 时显示错误、成功后清空表单。网络用 MSW 按场景切换。③ Storybook——把所有状态列成 Story(默认、错误、加载中、超长文本、无权限、只读),既作为团队文档,也让设计/产品能直接看到边界状态;接上 Chromatic 做视觉回归,接 axe 做 a11y 检查。加一条 E2E(Playwright)只覆盖"这个表单在完整业务链路里的那次提交",不做字段级校验(那是组件测试的活)。核心原则:把风险按成本最低的那一层去覆盖,E2E 只留最关键的一条。