跳到主要内容

Test-Driven Development (TDD) 是在写出让测试通过的 production code 之前,先写一条会失败的测试。在 frontend,这意味着把 UI 行为、accessibility 与 business rules 变成可执行的规格——而不是把测试当成最后的 verification。


  • 每个 cycle 交付一个 observable behavior,而不是整个 feature。
  • 用用户可见结果说话的测试,在你把 useState 换成 form library、reducer 或 server action 时仍然成立。
  • 分层、MSW,以及 E2E 该有多薄,见 Frontend Regression Tests。这篇讲的是 design habit:先红,再绿,再 refactor。


1. Red, Green, Refactor

TDD 是一个紧凑的循环:


  1. Red — 为你想要的行为写一条小测试。跑它。确认它因正确原因失败。
  2. Green — 实现让它通过的最简单代码。
  3. Refactor — 在测试保持绿色的同时,改进命名、结构与重复。

不要先写完一个 feature 的所有测试,再一次性实现。每个 loop 只交付一个行为。

购物车里的 quantity selector 是四个 cycle,不是一套 suite:


  • 点击 Increase,quantity 从 1 变成 2。
  • 点击 Decrease,永远不低于 1。
  • 改变 quantity 会重新计算显示的 subtotal。
  • 控件保持 keyboard-accessible。

Failure: 一条测试打开 cart、改 quantity、查 tax、套 coupon、再 checkout。它失败时,你分不清哪段行为坏了。



2. 为什么 Frontend TDD 不一样

Frontend code 处在几类关注点的交叉处:


  • Business rules: pricing、eligibility、validation、permissions。
  • User interaction: clicks、keyboard、focus、loading、errors。
  • Rendering: conditional content、responsive layouts、state transitions。
  • Accessibility: semantic elements、labels、focus management,必要时才用 ARIA。
  • Integration: APIs、storage、analytics、routing、feature flags。

TDD 有效的前提是:测试关注用户与周边系统能 observe 的东西,而不是私有实现——React state、component methods,或精确的 DOM nesting。

一条强测试会说:

用户提交无效凭证时,会看到可访问的 error,并且不会发出 sign-in request。

一条脆弱测试会说:

isInvalid state 为 true,组件有一个 class 为 error-textdiv

前者在你从 useState 迁到 form library、reducer、server action 或 state machine 时仍然成立。后者把实现锁死。


Failure: assert component.state、CSS class 列表,或特定的 div 树。用 role 与 accessible name 查询。



3. Walkthrough:一个 Search Form

一个产品搜索表单,四条要求:


  • 输入为空时,search button 禁用。
  • 输入非空 query 后,button 启用。
  • 提交前会 trim whitespace,再调用 search handler。
  • 用户可以用 Enter 提交。

从第一条行为开始。此时组件可能还不存在。


tsx
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

import { SearchForm } from "./search-form"

describe("SearchForm", () => {
  it("disables submission when the query is empty", () => {
    render(<SearchForm onSearch={vi.fn()} />)

    expect(
      screen.getByRole("button", { name: /search/i })
    ).toBeDisabled()
  })
})

只实现刚好满足这条 assertion 的代码:


tsx
import { useState } from "react"

type SearchFormProps = {
  onSearch: (query: string) => void
}

export function SearchForm({ onSearch }: SearchFormProps) {
  const [query, setQuery] = useState("")

  return (
    <form>
      <label htmlFor="product-search">Search products</label>

      <input
        id="product-search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />

      <button type="submit" disabled={!query.trim()}>
        Search
      </button>
    </form>
  )
}

下一个 cycle:有效 query 会启用 button。


tsx
import userEvent from "@testing-library/user-event"

it("enables submission after the user enters a query", async () => {
  const user = userEvent.setup()

  render(<SearchForm onSearch={vi.fn()} />)

  await user.type(
    screen.getByRole("textbox", { name: /search products/i }),
    "wireless headphones"
  )

  expect(screen.getByRole("button", { name: /search/i })).toBeEnabled()
})

然后规定提交行为:


tsx
it("submits a trimmed query", async () => {
  const user = userEvent.setup()
  const onSearch = vi.fn()

  render(<SearchForm onSearch={onSearch} />)

  await user.type(
    screen.getByRole("textbox", { name: /search products/i }),
    "  wireless headphones  "
  )

  await user.click(screen.getByRole("button", { name: /search/i }))

  expect(onSearch).toHaveBeenCalledWith("wireless headphones")
})

缺的实现现在很明显:


tsx
export function SearchForm({ onSearch }: SearchFormProps) {
  const [query, setQuery] = useState("")

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()

    const normalizedQuery = query.trim()

    if (normalizedQuery) {
      onSearch(normalizedQuery)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="product-search">Search products</label>

      <input
        id="product-search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />

      <button type="submit" disabled={!query.trim()}>
        Search
      </button>
    </form>
  )
}

Enter 提交是第四个 cycle:输入、按 Enter、assert onSearch。因为 handler 挂在 form 上,这条测试常常不需要额外代码就能过——仍然要确认它过了。

这些测试从不提及 useState。换成 React Hook Form、state machine,或 server-oriented form flow,它们仍然成立。它们通过 accessible controls 规定行为。


Failure: 先写完四条测试,再实现整个 form。一次只诊断一条红掉的 assertion。



4. 选择测试层级

让测试层级匹配行为的风险与范围。


Test levelBest forExample
UnitPure logic 与 transformationsPrice calculation、form validation、permission checks
Component单个 component 或 feature 的用户可见行为Modal opening、invalid form feedback、pagination
Integration多个 components 与 service boundaries搭配 mocked payment 与 inventory APIs 的 checkout
End-to-end关键的 real-browser workflowsSign-up、login、checkout、account recovery

把大多数行为放在快速的 unit 与 component tests 里。用 end-to-end tests 覆盖少量 critical journeys。

一套常见的 TypeScript frontend 配置:


  • Vitest 跑快速的 unit 与 component tests——TDD loop 发生在这里。
  • React Testing Library 做以交互为中心的 component tests。
  • Mock Service Worker (MSW) 在 network boundary mock HTTP,而不是 mock hooks。
  • Playwright 或 Cypress 跑 browser-level journeys。本站已发布的 playbook 用 Cypress;Playwright 填同一个位置。细节见 Frontend Regression Tests

Failure: 同一件事在每一层都证明一次。如果 Vitest 已经表明 coupon 会被拒绝,Cypress 不必再证明一遍算术。



5. 先测什么

TDD 在有实质行为、或 regression 代价很高的代码上回报最大。


Pure domain logic

把规则从 UI components 里抽出来。Checkout 界面不该直接拥有 tax、discounts 或 entitlement checks。


ts
export function calculateDiscount(
  subtotal: number,
  discountPercent: number
): number {
  if (subtotal <= 0 || discountPercent <= 0) {
    return 0
  }

  return Math.round(subtotal * (discountPercent / 100))
}

这些函数快、好读,并且不依赖 UI framework。


Form behavior 与 validation

Forms 有清晰的 input-output contracts:


  • Required fields、formatting 与 normalization。
  • Async validation 与 server-side error handling。
  • Submission、loading 与 disabled controls。
  • Accessible error announcements。

规定的是 API error 对用户可见,而不是内部 error 变量变了。


State transitions

复杂 UI 在你先命名 states 之后会更容易做:


  • Idle → loading → success。
  • Idle → loading → failure → retrying。
  • Editing → saving → saved。
  • Editing → saving → conflict resolution。

适合 onboarding、payments、uploads、approvals,以及必须显式处理 streaming、retry 与 partial failure 的 AI-assisted interfaces。


Accessibility behavior

Accessibility 是产品需求,不是上线后的 audit。


  • Control 有 accessible name。
  • Dialog 打开时获得 focus。
  • Keyboard user 能完成交互。
  • Error 对 assistive technology 可见。
  • Fields 与 labels 关联。

优先用 getByRolegetByLabelTextgetByText。只有在没有有意义的 accessible query 时,才用 test id。


Failure: 对 CSS class names、pixel offsets,或每个节点上的 data-testid 做 TDD。那些测试描述的是 markup,不是行为。



6. 常见错误

测试实现细节

不要检查 React state、调用 component methods,或 assert 精确 markup。

不要写成:

tsx
expect(component.state.isMenuOpen).toBe(true)

而要写成:

tsx
await user.click(screen.getByRole("button", { name: /open menu/i }))

expect(
  screen.getByRole("menu", { name: /account options/i })
).toBeVisible()

后者更接近用户,并且能在实现变化后仍然成立。


Over-mocking

把每个 hook、child 与 utility 都 mock 掉,测到的是你的 mocks。优先把真实 components 放在一起测。只在有意义的边界 mock:


  • HTTP requests。
  • 测试运行时缺失的 Browser APIs。
  • Payments、email、authentication,或第三方 SDKs。
  • Time、randomness,以及其他外部 side effects。

MSW 在 network layer 拦截。Frontend 继续使用真实的 data-fetching code;测试控制 response。


实现前写出过大的测试

一条带十次交互、二十个 assertions 的测试很难诊断。问:下一步能实现的最小 observable behavior 是什么?


把 snapshots 当成主测试

Snapshots 能抓住意外的 rendering 变化。它们很少说明 feature 是否真正工作。大 snapshots 会变成噪音,并在未经仔细 review 的情况下被批准。

用明确的 interaction assertions 测行为。如果要用 snapshots,保持小而有意。


忽略异步状态

现代 UI 很少是同步的。有意识地测 loading、empty、success、failure、retry、cancellation 与 stale data。

对 data-driven dashboard,通常包括:


  • 初始 loading indicator。
  • 成功渲染数据。
  • Empty state。
  • API failure message 与 retry。
  • Mutation pending 时禁用交互。
  • Response 在 navigation 或 input 已变化之后到达时的正确行为。

Failure: 只 assert happy path。Loading spinner 与 retry button,才是网络不友善时用户会撞上的行为。



7. TDD 与 Component Design

TDD 常常会揭露职责过多。如果一条测试在验证简单行为之前,需要 routing、auth、global stores、feature flags、API mocks、locale providers 与 browser APIs,代码就已经过度耦合。

更可维护的拆分:


text
features/
  checkout/
    domain/
      calculateTotal.ts
      validateCoupon.ts
    api/
      checkoutClient.ts
    components/
      CouponForm.tsx
      CheckoutSummary.tsx
    hooks/
      useCheckout.ts

不是每个 widget 都需要这套形状。Business rules 应可独立测试,UI 应暴露清晰行为,外部依赖应集中在边界。

在 multi-tenant SaaS 产品里,把 tenant policy 当成 pure logic 来测,再测 UI 对 allowed、denied 或 restricted 的响应。这比把 authorization 散落在视觉组件里安全得多。


Failure: 一份 200 行的 CheckoutPage 测试,setup 比 assertions 还长。抽出规则;用一个结果去测 widget。



8. 团队工作流,以及何时不必强推

TDD 要嵌进日常工程才会成功,而不是变成仪式。


  1. 澄清面向用户的行为与 acceptance criteria。
  2. 为最高价值的行为写一条会失败的测试。
  3. 实现让它通过的最小改动。
  4. Refactor production code 与 test setup。
  5. 重复,直到重要路径都被覆盖。
  6. 只为 critical customer journey 增加或更新 end-to-end test。
  7. 本地跑相关测试;让 CI 跑更广的 suite。

Code review 不该只问“有没有测试?”


  • 测试描述的是重要的用户或业务行为吗?
  • Feature 回归时它会失败吗?
  • 它使用 accessible、面向用户的 queries 吗?
  • 它耦合到私有实现选择了吗?
  • 有考虑 failure、loading 与 edge states 吗?
  • 它能在 CI 里稳定跑吗?

TDD 是工具,不是工程价值的度量。在探索视觉方向、快速 prototype,或需求高度不确定时,它用处较小。

一套常见的有效顺序:


  1. 快速 prototype 界面。
  2. 从 product、design 或用户反馈里学习。
  3. 把行为稳定下来。
  4. 在 refactor、扩展或大范围发布之前补上测试。

对 pixel-level 视觉要求,用 visual regression testing 与 design review 补 TDD。对复杂 browser workflows,用一小套 E2E 补 component tests。对 performance-sensitive 的工作,把测试与 performance budgets、real-user monitoring 配对。


Failure: 因为还没有失败的测试,就拦住一次 spike。先探索;在行为成为承重结构之前再锁住它。



Takeaway

Frontend TDD 的价值不是测试数量,也不是 100% coverage。它是 change 时的信心


  • 从 observable behavior 往外设计:定义用户必须完成的事,用一条失败测试证明它,简单地实现,再改进底下的代码。
  • 把 TDD loop 留在 Vitest 与 Testing Library。把 E2E 保持够薄。抽出 domain rules,别让它们活在 JSX 里。
  • 当你能重组 components、替换 state management,或迁移 API client,而不静默弄坏一条 workflow 时,测试就在做它该做的事。

Failure: 一套仍然会绿的 suite,即使此时用户已经完不成任务。


Recap Q&A