幕 V · 落地CH 21进度 21/24

Web 测试全景:Vitest、Testing Library、Playwright

如果你主攻 Android,这一章可以当作一次印证 —— 你会发现前端的测试栈,形状和 Android 一模一样,只是换了工具名。金字塔、单元/集成/端到端、替身、快照、flaky,全部原样适用。这不是巧合:测试的道理是跨平台的,变的只是 API。这一章快速过一遍前端的对应工具,重点讲一个东西 —— Testing Library 的那句设计哲学。因为它不只是前端的方法论,它其实是这整本书主张的一句话总结:测行为,别测实现。

Android↔Web 对照VitestTesting LibraryMSWPlaywright像用户一样

一张对照表:换汤不换药

把 Android 和 Web 的工具并排放,你会心一笑:

AndroidWeb
单元 / 逻辑JUnit5 + MockKVitest / Jest + vi.mock
组件测试Compose Test / RobolectricTesting Library(React/Vue/…)
拦网络MockWebServerMSW(Mock Service Worker)
截图Paparazzi / RoborazziPlaywright / Storybook 快照
端到端EspressoPlaywright / Cypress
属性测试kotest propertyfast-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 位"这段文字。这背后是一句被引用了无数遍的话:

◆ 判词 · Kent C. Dodds 的那句话,是全书的一句话总结

你的测试越像你的软件被使用的方式,它给你的信心就越足。」(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 爱按 DOM 结构查,而不是按用户视角

让 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 隔离区怎么设。