React Testing Strategy: From Unit Tests to E2E
React testing is not about testing everything; it is about testing the right things. Learn the React testing pyramid, what to unit-test, component-test, and cover with E2E, how to choose Vitest, Testing Library, and Playwright, and why behavior matters more than implementation.
Key Points
- Topic Focus: React、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
After years of writing React, I have seen two extreme kinds of teams. One puts test coverage on the wall as a KPI, runs CI for 20 minutes, and still breaks in production. The other thinks "testing wastes time," relies entirely on manual PR review, and accidentally removes the login flow while changing a button color. Neither approach wins.
React testing is difficult not because of the APIs, but because of deciding what to test. This article makes one thing clear: how to protect the greatest confidence with the lowest maintenance cost.
1. Start with One Principle: Test Behavior, Not Implementation
Remember this sentence. Every tool choice that follows is a consequence of it: test behavior users can see, not the code's internal structure.
The root of nearly all brittle tests is violating this principle. For example:
// 反面教材:测的是组件内部 state
expect(result.current._internalFlag).toBe(true)
expect(wrapper.find('FetchButton').prop('onClick')).toBeDefined()
A test like this has no value when it passes because it merely repeats your implementation. When it fails, it is all noise: you may simply have changed useState to useReducer, and the test turns red. Once refactoring begins, the first failures are all tests of this kind. Over time, the team starts to see "tests = drag," and the entire testing system gets abandoned.
The right approach is to ask yourself one question: how would a user know that this happened? Users cannot see state or props. They see only on-screen text, buttons, and navigation. Test those.
// 好的做法:测可观察的结果
expect(screen.getByText('已加载')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeDisabled()
This principle aligns "whether a test should change" with "whether user behavior changed": a test should turn red only when user behavior changes. That is the foundation of a high-quality testing system.
2. The Testing Pyramid: The Art of Dividing Work in Small, Effective Portions
The classic testing pyramid looks like this: many fast, cheap unit tests at the bottom; a moderate number of component tests in the middle; and a small number of slow, expensive E2E tests at the top. A useful rule-of-thumb ratio is roughly 7:2:1.
Why a pyramid rather than an inverted triangle? Because E2E tests are slow and prone to flakiness. If you pile on too many, CI becomes time everyone spends waiting in line for coffee. A suite with 200 all-E2E cases commonly takes more than ten minutes per run, and fluctuations in networking, timers, or animations can make any of them flaky. Move pure logic down into unit tests and reserve E2E for critical paths, and the overall suite can be both fast and stable.
Conversely, do not build a flat structure made entirely of unit tests. The greatest value of a React application is interaction, and pure unit tests cannot cover a chain like "a user clicks a button, then form validation, an API request, and UI feedback occur." Component tests are the middle layer that connects the two and cannot be omitted.
In one sentence: the pyramid is not dogma; it is cost-benefit economics.
3. Unit Tests: Give Pure Logic to Vitest
Any logic that does not depend on the React runtime should be covered by unit tests. This layer is the fastest, most stable, and the one that deserves the most tests.
For tooling, Vitest is the leading choice today. Its API is almost identical to Jest's, but it supports native ESM, starts quickly, needs less configuration, and works seamlessly with Vite projects. Jest is also fine; there is no need to force a migration in an older project.
A typical pure-function test:
// formatPrice.ts
export function formatPrice(cents: number, currency = 'CNY'): string {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
}).format(cents / 100)
}
// formatPrice.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'
describe('formatPrice', () => {
it('把分换算成元,带货币符号', () => {
expect(formatPrice(12345)).toBe('¥123.45')
})
it('支持多币种', () => {
expect(formatPrice(12345, 'USD')).toMatch(/123\.45/)
})
it('边界:0 和负数', () => {
expect(formatPrice(0)).toBe('¥0.00')
expect(formatPrice(-100)).toBe('-¥1.00')
})
})
Boundary values, including zero, negative values, empty arrays, and very large inputs, are where unit tests should work hardest. This is where ROI is highest.
Hooks can also be unit-tested. Since React 18, react-hooks-testing-library has been deprecated; use renderHook from @testing-library/react directly instead:
// useCounter.ts
import { useState, useCallback } from 'react'
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
const inc = useCallback(() => setCount(c => c + 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, inc, reset }
}
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('inc 让计数 +1', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.inc())
expect(result.current.count).toBe(6)
})
it('reset 回到初始值', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.inc())
act(() => result.current.reset())
expect(result.current.count).toBe(5)
})
})
But note this: if a hook has complex side effects, timers, or subscriptions, it is often better to cover it directly in component tests. Mocking those side effects separately is often more troublesome than rendering the component.
4. Component Tests: Testing Library's "User Perspective"
Component testing is the main battlefield of React projects. The entire @testing-library/react API is designed around the "user perspective." Remember its query priority ladder: getByRole > getByLabelText > getByText > getByTestId.
Why prioritize getByRole? Because it queries semantic roles, such as button, link, and textbox, which are exactly how screen-reader and keyboard users perceive the interface. If you can find a button with getByRole('button', { name: '登录' }), that button is accessible at the same time, so your test also performs an a11y audit incidentally. getByTestId should be a last resort. It is an escape hatch, not the default option.
Compare these two query styles:
// 偏实现:getByTestId 依赖你给元素贴的标签
await user.click(screen.getByTestId('login-submit'))
// 偏行为:getByRole 模拟的是用户视觉/语义
await user.click(screen.getByRole('button', { name: '登录' }))
Use userEvent rather than fireEvent for interaction. fireEvent directly dispatches low-level DOM events, while userEvent simulates a real user's operation chain, including focus before a click and the full keydown/keypress/input/keyup sequence while typing. In most cases, userEvent better reflects real behavior:
// LoginForm.tsx
import { useState } from 'react'
export function LoginForm({
onSubmit,
}: {
onSubmit: (email: string, password: string) => Promise<void>
}) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
return (
<form
onSubmit={async (e) => {
e.preventDefault()
setLoading(true)
try {
await onSubmit(email, password)
} finally {
setLoading(false)
}
}}
>
<label>
邮箱
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label>
密码
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
)
}
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('提交时把邮箱和密码交给 onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/邮箱/), '[email protected]')
await user.type(screen.getByLabelText(/密码/), 'secret')
await user.click(screen.getByRole('button', { name: '登录' }))
expect(onSubmit).toHaveBeenCalledWith('[email protected]', 'secret')
})
it('请求期间禁用按钮并显示 loading 文案', async () => {
let resolveSubmit!: () => void
const onSubmit = vi.fn(
() => new Promise<void>((resolve) => (resolveSubmit = resolve)),
)
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/邮箱/), '[email protected]')
await user.type(screen.getByLabelText(/密码/), 'secret')
await user.click(screen.getByRole('button', { name: '登录' }))
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveTextContent('登录中...')
resolveSubmit()
})
})
Notice the second case: it does not mock any internal state. It judges the loading state entirely through user-visible behavior: "the button is disabled + its text changes." That is what "test behavior" looks like in practice. If you later replace useState with Zustand or move synchronous logic into React Query, this test does not need a single change.
5. Mocking: Use MSW Instead of Mocking Modules Whenever Possible
Mocking is a double-edged sword. Too little mocking makes tests slow and unstable; too much mocking means you are testing mocks rather than code.
My priority order is as follows:
- Use MSW (Mock Service Worker) at the network layer. This is currently the most elegant solution: it intercepts real fetch or axios requests and returns responses you define. Component code does not know a mock exists, while you test real request logic, including URL construction, body serialization, and response parsing.
// test/server.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
export const server = setupServer(
http.post('/api/login', async ({ request }) => {
const { email } = (await request.json()) as { email: string }
return HttpResponse.json({ token: 'fake-jwt', email })
}),
http.get('/api/user', () =>
HttpResponse.json({ name: '张三', role: 'admin' }),
),
)
// test/setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest'
import { server } from './server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
onUnhandledRequest: 'error' is crucial. Any undeclared request fails, forcing you to write complete mocks and preventing the horror story of "the test passed, but the API does not exist in production at all."
-
Use module-level mocks only at boundaries. Examples include browser APIs jsdom does not implement, such as
localStorage,matchMedia, andIntersectionObserver, or time-related APIs such asDate.nowandsetTimeout. In these cases,vi.useFakeTimers()andvi.stubGlobal()are reasonable. -
Never mock your own business modules. The following pattern is an anti-pattern:
// 反模式:你 mock 掉了被测对象本身
vi.mock('./formatPrice', () => ({
formatPrice: () => '¥0.00',
}))
Once you do this, no future change to formatPrice can be detected by this test suite. Mocks should be used only at external boundaries, including the network, browser APIs, and third-party services, not inside the code under test.
6. E2E: Use Playwright to Protect Critical Flows
E2E is expensive, so test only flows where "if this breaks, the company loses money": registration, login, placing orders, payment, and the core conversion funnel. Keeping 5 to 15 E2E cases in a project is enough; beyond that, you are creating trouble for yourself.
For tooling, Playwright has now comprehensively surpassed Cypress. Its cross-browser support, native parallelism, auto-waiting, and Trace Viewer debugging experience are all in another league. Cypress's serial architecture and iframe sandbox are restrictive in complex scenarios, so choose Playwright by default for new projects.
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'
test('用户登录后完成下单', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('邮箱').fill('[email protected]')
await page.getByLabel('密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
// 等待真实跳转
await expect(page).toHaveURL(/\/dashboard/)
await page.getByText('商品 A').click()
await page.getByRole('button', { name: '加入购物车' }).click()
await page.getByRole('link', { name: /购物车/ }).click()
await page.getByRole('button', { name: '结算' }).click()
await expect(page.getByText('下单成功')).toBeVisible()
})
A few practical points matter here: E2E tests must use await expect(...) rather than asserting immediately after a bare await page.click(), because Playwright's auto-wait applies only to locators; tests should not share state, and each case should set up its own data, for example through API scripts; do not solve E2E flakiness by adding timeout(10000), because the root cause is data pollution or animation jitter.
7. Coverage: A Thermometer, Not a KPI
Coverage tools, including Vitest's --coverage and Istanbul, are useful, but using them in the wrong place is worse than not using them at all.
100% coverage does not mean you tested well. A project full of expect(true).toBe(true) can still achieve full coverage. Conversely, 80% coverage can be completely fine if the remaining 20% consists of error boundaries and unreachable branches.
The right approach is to treat coverage as a "health report," not a "KPI":
- Set a floor, such as 70%-80%; block CI below that number to prevent regression.
- Do not set a ceiling such as mandatory 100%, or the team will write useless tests just to inflate coverage.
- Focus on coverage in critical modules, such as payments, authentication, and billing, rather than the global average.
- Examine the coverage delta, which is more meaningful than the absolute value: was the code added in this PR tested?
Treat branches coverage as more important than lines coverage. Branch coverage forces you to test both sides of an if and cover boundary values.
8. What Not to Test: Save Your Energy for Real Work
The final section covers things you should not test. This is often more valuable than discussing what to test.
-
Do not test styles. Tests such as
expect(button).toHaveStyle('color: red')are almost entirely maintenance burden. The moment a designer changes a color, you update the test, even though anyone can see whether the color is correct. Visual regression belongs to visual-diff tools such as Percy and Chromatic, not unit tests. -
Do not test third-party library internals. If you use Zustand, React Query, or react-hook-form, do not test whether "Zustand's setState worked." That is the library author's responsibility. Testing it means working for them, and their upgrade can break your tests. Test only how you use the library, namely your business wrapper.
-
Do not test unstable dependencies. Real external APIs, the current time, random values, and the file system are all breeding grounds for flakiness. Either mock them at the boundary or move them into E2E and accept some variability. Calling real APIs in unit tests is a major mistake: it is slow, unstable, and can pollute other people's data.
-
Do not test types. The TypeScript compiler already checks types for you. Writing many assertions such as
expect(typeof x).toBe('string')is redundant. Unless you are testing runtime type guards, such as parsing unknown data returned by a backend, leave this totsc. -
Do not test trivial code. A one-line
return a + bdoes not need a test. Code review and the type system guarantee its correctness. The value of testing is in verifying complex behavior and preventing regressions, so keep your effort focused there.
Summary
- Test behavior, not implementation. Ask "how does the user perceive this?" rather than "what is the internal state?" This is the foundation of React testing.
- Use the pyramid structure. Have many fast unit tests for pure functions and hooks, a moderate number of component tests for interaction flows, and a small, focused E2E suite for critical workflows, at roughly 7:2:1.
- Tool selection in one sentence: use Vitest for units, Testing Library +
userEventfor components, MSW to mock networking, and Playwright for E2E. - Mock at boundaries. Use MSW for networking and
stubGlobalfor browser APIs. Never mock your own business code. - Coverage is a floor, not the goal. Use 70%-80% to prevent regression; 100% should not be a KPI. Focus on branch coverage and critical modules.
- Test fewer styles, fewer third-party internals, and fewer trivial lines of code. Use the saved effort to thoroughly test core workflows.
Key takeaway: a good test is one you do not have to change at all when refactoring.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.