Most React test suites fail for the same two reasons: they test implementation details that break on every refactor, and their end-to-end tests are flaky enough that people stop trusting them. Both are avoidable.
This guide is worked examples rather than theory. Versions used at the time of writing: Vitest 4.1, React Testing Library 16.3, Playwright 1.62 and Cypress 15.20.
The three layers, and what each is for
| Layer | What it tests | Tools | Speed | How many |
|---|---|---|---|---|
| Unit | One function or hook in isolation | Vitest, Jest | Milliseconds | Many |
| Integration | A component with its real children and state | Vitest + Testing Library | Fast | Most of your suite |
| End-to-end | A real browser against the running app | Playwright, Cypress | Seconds | Few, critical paths only |
The middle row is where the value is. Integration tests render real components with real state and assert on what a user would see, so they survive refactors and still catch genuine bugs. If you write only one kind of test, write these.
Setup: Vitest and Testing Library
npm i -D vitest @vitejs/plugin-react jsdom \
@testing-library/react @testing-library/user-event @testing-library/jest-dom
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
})
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
Unit test example
Pure logic needs no rendering. Test the function, not the component that calls it.
// src/lib/cart.test.ts
import { describe, it, expect } from 'vitest'
import { cartTotal } from './cart'
describe('cartTotal', () => {
it('sums price times quantity', () => {
const items = [
{ price: 10.5, qty: 2 },
{ price: 3.25, qty: 4 },
]
expect(cartTotal(items)).toBe(34)
})
it('returns 0 for an empty cart', () => {
expect(cartTotal([])).toBe(0)
})
})
Integration test example
Here is the rule that matters: query the way a user would. Use roles and labels, not test IDs or class names. A test that finds a button by its accessible name keeps working after a refactor — and fails if the button stops being reachable, which is a bug worth catching.
// src/components/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('submits the entered credentials', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'dev@example.com')
await user.type(screen.getByLabelText(/password/i), 'hunter2')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'dev@example.com',
password: 'hunter2',
})
})
it('shows a validation error for a bad email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'not-an-email')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/valid email/i)
})
})
Note findByRole in the second test. getBy throws immediately, findBy retries until the element appears. Using findBy for anything asynchronous removes most of the arbitrary waiting people add to tests.
Mocking the network with MSW
Do not mock fetch. Intercept at the network layer so the component runs its real data-fetching code.
// src/test/server.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
export const server = setupServer(
http.get('/api/projects', () => {
return HttpResponse.json([
{ id: 1, name: 'Apollo' },
{ id: 2, name: 'Borealis' },
])
}),
)
// src/components/ProjectList.test.tsx
import { render, screen } from '@testing-library/react'
import { beforeAll, afterAll, afterEach, it, expect } from 'vitest'
import { server } from '../test/server'
import { ProjectList } from './ProjectList'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('renders projects returned by the API', async () => {
render(<ProjectList />)
expect(await screen.findByText('Apollo')).toBeVisible()
expect(screen.getByText('Borealis')).toBeVisible()
})
End-to-end example: Playwright
npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'
test('a user can sign in and complete checkout', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('dev@example.com')
await page.getByLabel('Password').fill('hunter2')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
await page.getByRole('link', { name: 'Cart' }).click()
await page.getByRole('button', { name: 'Checkout' }).click()
await expect(page.getByText('Order confirmed')).toBeVisible()
await expect(page).toHaveURL(/\/orders\/\d+/)
})
Playwright waits for elements to be visible, stable and enabled before acting, so explicit sleeps are almost never needed. trace: 'on-first-retry' is the single most useful setting here: when a test fails in CI you get a recording of the run rather than a stack trace.
End-to-end example: Cypress
// cypress/e2e/checkout.cy.ts
describe('checkout', () => {
it('lets a signed-in user complete an order', () => {
cy.visit('/login')
cy.findByLabelText(/email/i).type('dev@example.com')
cy.findByLabelText(/password/i).type('hunter2')
cy.findByRole('button', { name: /sign in/i }).click()
cy.findByRole('heading', { name: /dashboard/i }).should('be.visible')
cy.findByRole('link', { name: /cart/i }).click()
cy.findByRole('button', { name: /checkout/i }).click()
cy.contains('Order confirmed').should('be.visible')
cy.url().should('match', /\/orders\/\d+/)
})
})
Those findBy* commands come from @testing-library/cypress, which keeps your queries consistent with the integration tests above. Without it you end up with CSS selectors that break on every styling change.
Playwright or Cypress?
| Playwright 1.62 | Cypress 15.20 | |
|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit |
| Parallelism | Built in, free | Built in; dashboard for orchestration |
| CI sharding | One flag | Via Cypress Cloud or manual split |
| Debugging | Trace viewer after the run | Time-travel runner, live |
| Multi-tab / multi-origin | Supported | Limited |
| Visual comparison | Built in | Plugin |
| Learning curve | Medium | Low |
Choose Playwright for speed, cross-browser coverage and CI scale — it is the stronger default in 2026. Choose Cypress if your team values the interactive time-travel runner, which is still the most pleasant local debugging experience available.
Do not run both. Two E2E suites means two sets of flaky tests and nobody owning either.
Running E2E tests in CI
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
Four shards turn a twelve-minute suite into roughly three. Uploading the report on failure matters more than it sounds — without it, a CI-only failure is nearly impossible to diagnose.
Why tests go flaky
- Fixed sleeps.
waitForTimeout(2000)is a guess. It is too long on a fast machine and too short on a loaded CI runner. Wait for a condition instead. - Shared state between tests. If test B only passes after test A has run, they are one test. Seed the state each test needs.
- Real third-party calls. An external API in an E2E test means someone else's downtime fails your build. Stub it at the network layer.
- Selectors tied to styling.
.btn-primary-2breaks on the next redesign. Roles and labels do not. - Time and randomness. Freeze the clock and seed random generators, or a test will fail at midnight or once every hundred runs.
What not to test
Do not assert on internal state, prop names or the number of times a hook rendered — that is testing React, not your application. Do not snapshot large component trees; those snapshots get regenerated without being read.
A useful check before writing any test: if this test fails, will it tell me something is broken for a user? If not, it is maintenance cost without a safety benefit.
Frequently asked questions
What is a good React E2E test example?
A single critical user journey run in a real browser — sign in, add to cart, check out — asserting only on what the user sees. The Playwright example above is a complete, runnable version.
Should I use Playwright or Cypress for React in 2026?
Playwright is the stronger default: faster, genuinely cross-browser, with free parallelism and one-flag CI sharding. Cypress remains excellent if your team values its interactive time-travel debugger. Pick one.
Is Vitest better than Jest for React?
For Vite projects, yes — it reuses your Vite config, starts faster and needs almost no setup. For an existing Jest suite that works, migration is optional; Jest 30 is still actively maintained.
How many E2E tests should I write?
Few. Cover the journeys that lose money or block users if broken — authentication, checkout, publishing. Everything else belongs in integration tests, which are faster and far less flaky.
Why do my React tests break on every refactor?
They are querying implementation details — test IDs, class names, component internals. Query by role and label instead, and tests survive refactors while still catching real breakage.
How do I test components that fetch data?
Intercept at the network layer with MSW rather than mocking fetch. The component then runs its real data-fetching code, so you test what actually ships.
How do I stop flaky end-to-end tests?
Remove fixed sleeps, isolate state between tests, stub third-party calls, use role-based selectors, and freeze time and randomness. Those five cover the large majority of flakiness.
What test coverage percentage should I target?
No specific number. Coverage shows which lines ran, not whether behaviour is correct. A suite at 60% covering real user journeys beats one at 95% asserting on internals.
Comments (0)
Leave a Comment
No comments yet. Be the first to share your thoughts!