Web 测试全景:Vitest、Testing Library、Playwright
如果你主攻 Android,这一章可以当作一次印证 —— 你会发现前端的测试栈,形状和 Android 一模一样,只是换了工具名。金字塔、单元/集成/端到端、替身、快照、flaky,全部原样适用。这不是巧合:测试的道理是跨平台的,变的只是 API。这一章快速过一遍前端的对应工具,重点讲一个东西 —— Testing Library 的那句设计哲学。因为它不只是前端的方法论,它其实是这整本书主张的一句话总结:测行为,别测实现。
一张对照表:换汤不换药
把 Android 和 Web 的工具并排放,你会心一笑:
| 层 | Android | Web |
|---|---|---|
| 单元 / 逻辑 | JUnit5 + MockK | Vitest / Jest + vi.mock |
| 组件测试 | Compose Test / Robolectric | Testing Library(React/Vue/…) |
| 拦网络 | MockWebServer | MSW(Mock Service Worker) |
| 截图 | Paparazzi / Roborazzi | Playwright / Storybook 快照 |
| 端到端 | Espresso | Playwright / Cypress |
| 属性测试 | kotest property | fast-check |
连坑都一样:sleep 导致的 flaky、过度 mock、只测 happy path、快照橡皮图章 —— 前面所有章节的道理,一条不落地照搬过来。所以这一章不重复那些,只讲前端特有的那点味道。
Vitest:前端的单元层
Vitest(以及老牌的 Jest)就是前端的 JUnit。纯逻辑函数,写法和你想的一样:
import { describe, it, expect } from 'vitest'
describe('splitBill', () => {
it('均分后总额不变', () => {
const parts = splitBill(1000, 3)
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000) // 第 1 章那条属性
})
})
纯逻辑(计算、格式化、状态归约)在这一层测,和 Android 的 JVM 单元测试一样又快又该多写。
Testing Library:像用户一样查询
前端最有意思、也最能体现现代测试哲学的,是 Testing Library。它测组件,但它刻意不让你碰实现细节。看它怎么查元素:
test('输错密码显示错误提示', async () => {
render(<LoginForm />)
// 按"用户看得见的东西"查:标签、角色、文本 —— 不是 class、不是内部 state
await userEvent.type(screen.getByLabelText('邮箱'), 'a@b.com')
await userEvent.type(screen.getByLabelText('密码'), '123')
await userEvent.click(screen.getByRole('button', { name: '登录' }))
expect(screen.getByText('密码至少 8 位')).toBeVisible()
})
注意:它没有去读组件的内部 state、没有按 CSS class 或 data-testid 找元素(那是最后手段)。它按用户感知的方式操作 —— 找"邮箱"标签、找"登录"按钮、看"密码至少 8 位"这段文字。这背后是一句被引用了无数遍的话:
「你的测试越像你的软件被使用的方式,它给你的信心就越足。」(The more your tests resemble the way your software is used, the more confidence they give you.)
为什么按用户视角查元素?因为用户不在乎你的 class 叫什么、state 怎么存 —— 用户在乎"我能不能看到登录按钮、点了有没有反应"。测试贴着用户视角,就天然测了行为、避开了实现:你把 <div> 重构成 <section>、把 class 改个名,测试不红(用户体验没变);你把"登录"按钮弄没了,测试立刻红(用户真的用不了了)。
这不就是第 11 章「测行为,别测实现」、第 7 章「按用户可见的东西定位」的同一句话吗?Testing Library 只是把这个哲学做进了 API 的默认姿势里 —— 它让"正确的测法"成为最省力的那条路。这是优秀测试工具的标志:让你自然而然写出好测试。
MSW:在边界上拦网络
组件要调后端接口,你不想打真 API。MSW 在网络层拦住请求,返回你准备的响应 —— 和 Android 的 MockWebServer 同一个思路(在架构边界上替身,第 11 章):
const server = setupServer(
http.get('/api/user/:id', () =>
HttpResponse.json({ id: 1, name: 'Ann' })) // 拦住这个请求,回假数据
)
// 组件里真实的 fetch 调用会被 MSW 接管,代码本身一行都不用改
MSW 的好处是它拦在最接近真实的那一层(网络),你的组件代码用的还是真的 fetch/axios —— 于是你测到了"发请求、拿响应、渲染"这整条真实路径,只有网络那一跳是假的。这比 mock 掉整个数据层更接近真实,也更抗重构。
Playwright:端到端
端到端还是第 7 章讲过的 Playwright,真浏览器里跑完整旅程。它的 getByRole 查询和自动等待,贯彻的是同样两条原则:按用户视角定位、等条件不等时间。不再重复。
AI 与前端测试
让 AI 写 Testing Library 测试,它有个顽固倾向:用 container.querySelector('.login-btn') 或 getByTestId('submit') 去找元素 —— 而不是 getByRole('button', { name: '登录' })。前者绑死在实现细节(class 名、DOM 结构)上,一重构就碎;后者贴着用户视角,抗重构、还顺带验证了无障碍。
这是第 7、11、18 章那个"测实现而非行为"在前端的具体形态。审查 AI 的前端测试,盯一件事:它是按用户看得见的东西(角色、标签、文本)查,还是按代码内部结构(class、testid、DOM 路径)查?后者越多,测试越脆、越没在测真正重要的东西。
Testing Library 明令禁止的那些「实现细节」
Testing Library 之所以能逼你写出好测试,是因为它刻意不给你访问实现细节的 API。它把「实现细节」列得很清楚 —— 这份清单本身就是一份「什么不该测」的教材:
| 别测这个(实现细节) | 因为 |
|---|---|
| 组件的内部 state | 用户看不到 state,只看到渲染结果。测 state = 测实现 |
| 某个方法有没有被调用 | 那是「怎么做的」,不是「做到了什么」(第 11 章) |
CSS 类名 / DOM 结构 / data-testid | 重构 DOM 就碎;用户不按 class 找东西 |
| 子组件的 props | 内部接线,改了不影响用户体验 |
它推荐的查询方式有个明确的优先级:先 getByRole(角色 + 无障碍名,最像用户)、再 getByLabelText(表单)、getByText(可见文本),getByTestId 排在最后 —— 只在实在没有语义化方式时才用。这个优先级不是洁癖,它同时在推动你写更可访问(accessible)的界面:能用角色和标签查到的组件,也就是屏幕阅读器能读到的组件。好测试和好无障碍,在这里是同一件事。
MSW:一个 handler 就够
MSW 拦网络的写法很直白,而且它拦在真正的网络层,连组件里的 fetch 都不用改:
const server = setupServer(
http.get('/api/user/:id', ({ params }) => {
if (params.id === '404') return new HttpResponse(null, { status: 404 })
return HttpResponse.json({ id: Number(params.id), name: 'Ann' })
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // 每个测试后重置,保持独立(FIRST-I)
afterAll(() => server.close())
test('加载失败时显示错误', async () => {
server.use(http.get('/api/user/:id', () => HttpResponse.error())) // 单测覆盖:模拟网络错
render(<UserCard id="1" />)
expect(await screen.findByText('加载失败')).toBeVisible()
})
注意 afterEach(resetHandlers) —— 它保证每个测试从干净的网络桩开始,不被上一个测试的 server.use 污染。这是第 5 章 FIRST-I(独立)在前端的落地:共享的 mock 一定要在测试间重置,否则就是 flaky 的温床。
1. 拿那张对照表,给你熟悉的平台找到每一层的工具。确认"道理相同、工具不同"这件事。
2. 找一个按 testid / class 查询的前端测试,改成按 getByRole / getByLabelText。看它是不是更抗重构、也读起来更像用户行为。
3. 把 Kent C. Dodds 那句话和第 11 章连起来读:它们是同一件事在两个平台上的表达。
工具铺完了。但测试写得再好,不自动跑、不挡合并,就等于没有。下一章讲怎么把这一切接进 CI:分层流水线(冒烟秒级、PR 分钟级、夜间全量)、分片并行、必需检查,以及 flaky 隔离区怎么设。