How to Set Up Automated Testing in a React Project: Vitest, React Testing Library, and Playwright

Adding automated testing to a React project is one of those tasks that stays in the backlog for months, mostly because the tooling advice online is fragmented: one article covers Jest, another covers Cypress, and none of them show how the pieces fit together in a real codebase. This tutorial fixes that. In a single afternoon you will bolt a complete, production-grade testing stack onto an existing React app: Vitest for unit tests, React Testing Library for component tests, MSW for API mocking, and Playwright for end-to-end tests, all wired into GitHub Actions with coverage thresholds that actually fail the build.

Every snippet below is copy-paste ready. We build two real test suites that mirror what you have in almost every app: a signup form (validation, submission, pending state) and an API-driven list (loading, success, empty, error).

The testing stack at a glance

Automated testing in a React project works best as layers. Each layer answers a different question, runs at a different speed, and breaks for a different reason.

Layer Tool What it verifies Typical speed Share of your suite
Static analysis TypeScript + ESLint Types, dead code, bad hooks usage Seconds Always on
Unit Vitest Pure functions, reducers, validators, hooks Milliseconds ~50%
Component / integration Vitest + React Testing Library + MSW Rendered UI, user interactions, network states Tens of ms ~40%
End-to-end Playwright Critical user journeys in a real browser Seconds per test ~10%

Why Vitest instead of Jest in 2026

Jest still works and is still everywhere, but if your React app is built with Vite (the default for most new projects since Create React App was deprecated), Vitest is the lower-friction choice.

Criterion Vitest Jest
Config Reuses your existing vite.config.ts (aliases, env, plugins) Separate config plus a transform (babel-jest, ts-jest or SWC)
TypeScript and ESM Native Workable, but ESM still needs care
Watch mode speed Very fast, module-graph aware Good, slower cold start
API compatibility Jest-compatible (describe, it, expect, mocks) The reference API
Browser-mode component tests Built in (real browser, no jsdom) Not available

Migrating an existing Jest suite? Most files run unchanged once you replace jest.fn() with vi.fn() and jest.mock() with vi.mock(). Keep Jest if you are on Next.js with a heavy Babel setup and you have no pain today; the rest of this guide still applies almost line for line, since React Testing Library, MSW and Playwright are runner-agnostic. reactjs.org makes the same point with more data.

react code testing laptop

Prerequisites

  • An existing React 19 app scaffolded with Vite (TypeScript recommended)
  • Node.js 22 LTS or newer
  • A package manager: npm, pnpm or yarn (commands below use npm)
  • Roughly 3 hours of uninterrupted time

Step 1: Install the unit and component testing layer

npm install -D vitest @vitest/coverage-v8 jsdom \
  @testing-library/react @testing-library/dom @testing-library/user-event \
  @testing-library/jest-dom msw

What each package does:

  • vitest: the test runner and assertion library
  • @vitest/coverage-v8: coverage reporting via the V8 engine
  • jsdom: a DOM implementation so components can render in Node
  • @testing-library/react: render components and query them the way a user sees them
  • @testing-library/user-event: realistic typing, clicking, tabbing and pasting
  • @testing-library/jest-dom: DOM matchers such as toBeVisible() and toHaveAccessibleName()
  • msw: intercepts network requests at the HTTP layer instead of mocking fetch
react code testing laptop

Step 2: Configure Vitest

Import defineConfig from vitest/config so the test key is typed. Note the exclude entry: without it, Vitest will try to run your Playwright specs and fail.

// vite.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
    clearMocks: true,
    restoreMocks: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/*.{test,spec}.{ts,tsx}',
        'src/test/**',
        'src/main.tsx',
        'src/**/*.d.ts',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
        statements: 80,
      },
    },
  },
})

The setup file

This single file registers the DOM matchers, cleans the DOM between tests, and starts the MSW server for the whole suite.

// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './msw/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

afterEach(() => {
  cleanup()
  server.resetHandlers()
})

afterAll(() => server.close())

Important: onUnhandledRequest: 'error' is what turns MSW into a safety net. If a component starts calling an endpoint nobody mocked, the test fails loudly instead of silently hanging.

Mock handlers and MSW server

// src/test/msw/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () =>
    HttpResponse.json([
      { id: 1, name: 'Ada Lovelace', email: '[email protected]' },
      { id: 2, name: 'Alan Turing', email: '[email protected]' },
    ]),
  ),

  http.post('/api/signup', () =>
    HttpResponse.json({ id: 42 }, { status: 201 }),
  ),
]
// src/test/msw/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Add the scripts

// package.json (excerpt)
{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "lint": "eslint .",
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:all": "npm run test:run && npm run test:e2e"
  }
}

Run npm test now. Vitest should start in watch mode and report that no tests were found. That is your green light.

Step 3: Component test number one, the signup form

Forms are where most regressions hide: validation rules, disabled buttons, double submits, error announcements. Here is the component under test.

// src/components/SignupForm.tsx
import { useState, type FormEvent } from 'react'

type Values = { email: string; password: string }
type Props = { onSubmit: (values: Values) => Promise<void> | void }
type Errors = { email?: string; password?: string }

export function SignupForm({ onSubmit }: Props) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [errors, setErrors] = useState<Errors>({})
  const [pending, setPending] = useState(false)

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

    const next: Errors = {}
    const cleanEmail = email.trim()
    if (!/^\S+@\S+\.\S+$/.test(cleanEmail)) next.email = 'Enter a valid email address'
    if (password.length < 8) next.password = 'Password must be at least 8 characters'

    setErrors(next)
    if (Object.keys(next).length > 0) return

    setPending(true)
    try {
      await onSubmit({ email: cleanEmail, password })
    } finally {
      setPending(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <h1>Create your account</h1>

      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        value={email}
        aria-invalid={Boolean(errors.email)}
        aria-describedby={errors.email ? 'email-error' : undefined}
        onChange={(event) => setEmail(event.target.value)}
      />
      {errors.email && (
        <p id="email-error" role="alert">{errors.email}</p>
      )}

      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        type="password"
        value={password}
        aria-invalid={Boolean(errors.password)}
        aria-describedby={errors.password ? 'password-error' : undefined}
        onChange={(event) => setPassword(event.target.value)}
      />
      {errors.password && (
        <p id="password-error" role="alert">{errors.password}</p>
      )}

      <button type="submit" disabled={pending}>
        {pending ? 'Creating account...' : 'Create account'}
      </button>
    </form>
  )
}

The test file

// src/components/SignupForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { SignupForm } from './SignupForm'

describe('SignupForm', () => {
  it('blocks submission and announces errors when the fields are invalid', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn()
    render(<SignupForm onSubmit={onSubmit} />)

    await user.click(screen.getByRole('button', { name: /create account/i }))

    const alerts = await screen.findAllByRole('alert')
    expect(alerts).toHaveLength(2)
    expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true')
    expect(onSubmit).not.toHaveBeenCalled()
  })

  it('rejects a password shorter than 8 characters', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn()
    render(<SignupForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText('Email'), '[email protected]')
    await user.type(screen.getByLabelText('Password'), 'short')
    await user.click(screen.getByRole('button', { name: /create account/i }))

    expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument()
    expect(onSubmit).not.toHaveBeenCalled()
  })

  it('submits trimmed values when the form is valid', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn().mockResolvedValue(undefined)
    render(<SignupForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText('Email'), '  [email protected]  ')
    await user.type(screen.getByLabelText('Password'), 'supersecret1')
    await user.click(screen.getByRole('button', { name: /create account/i }))

    expect(onSubmit).toHaveBeenCalledTimes(1)
    expect(onSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'supersecret1',
    })
    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
  })

  it('disables the button while the submission is pending', async () => {
    const user = userEvent.setup()
    let resolveSubmit: () => void = () => {}
    const onSubmit = vi.fn(
      () => new Promise<void>((resolve) => { resolveSubmit = resolve }),
    )
    render(<SignupForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText('Email'), '[email protected]')
    await user.type(screen.getByLabelText('Password'), 'supersecret1')
    await user.click(screen.getByRole('button', { name: /create account/i }))

    const button = screen.getByRole('button', { name: /creating account/i })
    expect(button).toBeDisabled()

    resolveSubmit()
    expect(await screen.findByRole('button', { name: /create account/i })).toBeEnabled()
  })
})

Why these tests are robust

  • They query by role and label, not by class name or test id, so a CSS refactor never breaks them
  • They assert on behaviour (was the callback called, is the button disabled) rather than internal state
  • userEvent.setup() is called once per test, which is the supported pattern and keeps keyboard state clean
react code testing laptop

Step 4: Component test number two, the API-driven list

This is where MSW pays for itself. You test the four states of any remote data component without touching global.fetch. The team at testrigor.com reached a similar conclusion.

// src/components/UserList.tsx
import { useEffect, useState } from 'react'

export type User = { id: number; name: string; email: string }

export function UserList() {
  const [users, setUsers] = useState<User[]>([])
  const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')

  useEffect(() => {
    let active = true

    fetch('/api/users')
      .then((response) => {
        if (!response.ok) throw new Error('Request failed')
        return response.json() as Promise<User[]>
      })
      .then((data) => {
        if (!active) return
        setUsers(data)
        setStatus('success')
      })
      .catch(() => {
        if (active) setStatus('error')
      })

    return () => { active = false }
  }, [])

  if (status === 'loading') return <p role="status">Loading users...</p>
  if (status === 'error') return <p role="alert">We could not load the user list.</p>
  if (users.length === 0) return <p>No users yet.</p>

  return (
    <ul aria-label="Users">
      {users.map((user) => (
        <li key={user.id}>
          <span>{user.name}</span>
          <a href={`mailto:${user.email}`}>{user.email}</a>
        </li>
      ))}
    </ul>
  )
}
// src/components/UserList.test.tsx
import { render, screen } from '@testing-library/react'
import { http, HttpResponse, delay } from 'msw'
import { describe, expect, it } from 'vitest'
import { server } from '../test/msw/server'
import { UserList } from './UserList'

describe('UserList', () => {
  it('shows a loading state and then the fetched users', async () => {
    render(<UserList />)

    expect(screen.getByRole('status')).toHaveTextContent(/loading users/i)

    const list = await screen.findByRole('list', { name: 'Users' })
    expect(list).toBeInTheDocument()
    expect(screen.getAllByRole('listitem')).toHaveLength(2)
    expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
    expect(screen.getByRole('link', { name: '[email protected]' }))
      .toHaveAttribute('href', 'mailto:[email protected]')
  })

  it('renders an empty state when the API returns no user', async () => {
    server.use(http.get('/api/users', () => HttpResponse.json([])))

    render(<UserList />)

    expect(await screen.findByText(/no users yet/i)).toBeInTheDocument()
    expect(screen.queryByRole('list')).not.toBeInTheDocument()
  })

  it('renders an error message when the API fails', async () => {
    server.use(
      http.get('/api/users', async () => {
        await delay(10)
        return new HttpResponse(null, { status: 500 })
      }),
    )

    render(<UserList />)

    expect(await screen.findByRole('alert'))
      .toHaveTextContent(/could not load the user list/i)
  })
})

Two details worth remembering:

  1. Relative URLs work because jsdom gives the test a location origin, and MSW intercepts at the request layer. No base URL juggling needed.
  2. server.use(...) overrides a handler for a single test only, since resetHandlers() runs in afterEach. Your default happy path stays in handlers.ts.

Testing custom hooks

If your data fetching lives in a hook (TanStack Query, SWR or your own), test it directly with renderHook and keep the component tests focused on rendering:

import { renderHook, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { useUsers } from './useUsers'

it('exposes the fetched users', async () => {
  const { result } = renderHook(() => useUsers())

  await waitFor(() => expect(result.current.isLoading).toBe(false))
  expect(result.current.users).toHaveLength(2)
})

Step 5: Turn coverage into a real quality gate

Coverage numbers only matter if they can fail the build. Run:

npm run test:coverage

Vitest prints a table and writes an HTML report to coverage/index.html. If you are retrofitting automated testing on an existing React project, do not start at 80%. Use this ratchet strategy instead:

  1. Run coverage once and note your real numbers (many legacy apps start around 15%)
  2. Set the thresholds just below those numbers so the build is green today
  3. Raise them by 5 points every sprint, or let Vitest do it for you with thresholds: { autoUpdate: true } during the catch-up phase
  4. Once you pass 70%, switch to thresholds: { '100': false, lines: 80, ... } and freeze the numbers

Better still, gate on changed code rather than the whole repo so new work is always tested:

vitest run --coverage --changed origin/main
Metric Pragmatic target Comment
Lines / statements 80% Good signal, cheap to reach
Branches 70% The one that catches missing error states
Functions 80% Exclude generated and barrel files
100% anywhere Avoid Encourages tests written for the metric, not the user
react code testing laptop

Step 6: End-to-end tests with Playwright

Component tests run against jsdom, which is not a browser. Playwright covers the last mile: real routing, real navigation, real Chromium, Firefox and WebKit.

npm init playwright@latest

Choose TypeScript, put the tests in e2e, and let it install the browsers. Then replace the generated config:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

const PORT = 4173
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: Boolean(process.env.CI),
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI
    ? [['github'], ['html', { open: 'never' }]]
    : [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
  ],
  webServer: {
    command: `npm run build && npm run preview -- --port ${PORT}`,
    url: baseURL,
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
})

Testing the production build through vite preview instead of the dev server is deliberate: you catch minification, env variable and routing issues before your users do.

An end-to-end spec for the signup journey

// e2e/signup.spec.ts
import { expect, test } from '@playwright/test'

test.describe('signup journey', () => {
  test('a visitor can create an account and lands on the dashboard', async ({ page }) => {
    await page.route('**/api/signup', (route) =>
      route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify({ id: 42, email: '[email protected]' }),
      }),
    )

    await page.goto('/signup')

    await page.getByLabel('Email').fill('[email protected]')
    await page.getByLabel('Password').fill('supersecret1')
    await page.getByRole('button', { name: 'Create account' }).click()

    await expect(page).toHaveURL(/\/dashboard/)
    await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible()
  })

  test('validation errors are shown and no request is sent', async ({ page }) => {
    let requests = 0
    await page.route('**/api/signup', (route) => { requests += 1; return route.abort() })

    await page.goto('/signup')
    await page.getByRole('button', { name: 'Create account' }).click()

    await expect(page.getByText('Enter a valid email address')).toBeVisible()
    await expect(page.getByText('Password must be at least 8 characters')).toBeVisible()
    expect(requests).toBe(0)
  })
})
// e2e/users.spec.ts
import { expect, test } from '@playwright/test'

test('the users page renders the list returned by the API', async ({ page }) => {
  await page.route('**/api/users', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Ada Lovelace', email: '[email protected]' },
        { id: 2, name: 'Alan Turing', email: '[email protected]' },
      ]),
    }),
  )

  await page.goto('/users')

  const list = page.getByRole('list', { name: 'Users' })
  await expect(list.getByRole('listitem')).toHaveCount(2)
  await expect(list).toContainText('Ada Lovelace')
})

test('the users page shows a friendly error when the API is down', async ({ page }) => {
  await page.route('**/api/users', (route) => route.fulfill({ status: 500 }))

  await page.goto('/users')

  await expect(page.getByRole('alert')).toContainText('could not load')
})

Playwright rules that prevent flaky suites

  • Use web-first assertions (await expect(locator).toBeVisible()). They auto-retry, so you never need waitForTimeout
  • Never use page.waitForTimeout() in a committed test
  • Prefer getByRole and getByLabel over CSS selectors, exactly like in React Testing Library
  • Keep the E2E suite small: happy paths for signup, login, checkout, search. Everything else belongs in component tests
  • Generate a first draft quickly with npx playwright codegen http://localhost:4173, then clean up the selectors by hand

Step 7: Wire everything into CI

Automated tests that only run locally are optional tests. Add this workflow and they become a gate.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  static-and-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit
      - run: npm run test:coverage
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/
          retention-days: 7

  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2]
    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
      - run: npx playwright test --shard=${{ matrix.shard }}/2
      - name: Upload Playwright report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/
          retention-days: 7

Notes:

  • The two jobs run in parallel, so a full pipeline on a small app finishes in about 4 to 6 minutes
  • --shard splits the browser matrix across runners; bump the shard count as your suite grows
  • Uploading the coverage and Playwright HTML reports as artifacts means a failing PR gives you a trace to open locally, not just a red X
  • Finally, protect main in your repository settings and mark both jobs as required status checks

Optional: a fast pre-commit hook

npm install -D husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit
// package.json (excerpt)
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "vitest related --run --passWithNoTests"
    ]
  }
}

vitest related only runs the tests that import the staged files, which keeps the hook under a couple of seconds. Leave Playwright out of pre-commit; it belongs in CI.

react code testing laptop

Your afternoon plan

  1. 0:00 to 0:20 Install dependencies, configure Vitest, create the setup file
  2. 0:20 to 0:35 Add MSW handlers and the node server
  3. 0:35 to 1:20 Write the form component test suite
  4. 1:20 to 2:00 Write the API-driven list test suite (loading, success, empty, error)
  5. 2:00 to 2:15 Run coverage, set thresholds just below your current numbers
  6. 2:15 to 2:50 Install and configure Playwright, write two end-to-end specs
  7. 2:50 to 3:10 Add the GitHub Actions workflow and make both jobs required
  8. 3:10 to 3:30 Document the commands in your README so the next developer has no excuse

Common mistakes to avoid

Mistake Why it hurts Do this instead
Querying by class or test id everywhere Tests break on refactors and pass on broken accessibility Use getByRole, getByLabelText, getByText first
Snapshot testing whole pages Huge diffs that everyone blindly updates Assert on the few elements that matter
Stubbing global.fetch by hand Leaks between tests, misses headers and status codes Intercept with MSW
Recreating the whole user journey in E2E Slow, flaky pipelines nobody trusts Keep E2E to critical paths, push the rest down a layer
Using fireEvent for typing Skips focus, key events and browser behaviour Use userEvent
Chasing 100% coverage Tests written for the metric, not for users 80/70 thresholds plus coverage on changed files

FAQ

Is there a React testing library called Jest?

No, they are two different tools that are often used together. Jest is a test runner and assertion library, while React Testing Library is a set of helpers that render components and query them the way a user would. In this tutorial Vitest plays the runner role and React Testing Library plays the rendering role.

What is better than Jest?

For a Vite-based React project, Vitest is generally better: it reuses your existing Vite config and aliases, handles TypeScript and ESM natively, starts faster in watch mode, and keeps a Jest-compatible API so migration is mostly mechanical. If your app is built with Next.js and a Babel-heavy setup that already works, staying on Jest is perfectly reasonable. This overview fills in the gaps.

Which tool is best for automation testing of a React app?

There is no single tool, there is a stack. Unit and component tests: Vitest + React Testing Library. Network mocking: MSW. End-to-end and cross-browser: Playwright. Playwright has largely become the default over Selenium and Cypress for new projects thanks to built-in parallelism, tracing and WebKit support.

Do I still need Enzyme?

No. Enzyme is not maintained for modern React versions and its adapter model never followed React past version 17. Anything you did with shallow rendering is better expressed as a React Testing Library test on behaviour, or as a plain unit test on the extracted logic.

How do I test React Server Components and Next.js App Router pages?

Async server components cannot be rendered by React Testing Library today. The practical split is: unit test the data-loading functions and utilities with Vitest, component test your client components ("use client") with React Testing Library, and cover server-rendered pages with Playwright against a real build.

How can I automate React Native testing?

The unit and component layers stay very similar: Jest or Vitest plus @testing-library/react-native. For the end-to-end layer you swap Playwright for Maestro or Detox, which drive real simulators and devices. The pyramid, the mocking approach and the CI structure described here all transfer.

How long should the whole suite take?

Aim for under 30 seconds locally for unit and component tests, and under 10 minutes in CI including end-to-end. If you exceed that, shard Playwright across more runners and move assertions that do not need a browser down to the component layer.

Should AI generate my tests?

AI assistants are excellent at drafting the repetitive parts: extra edge cases, fixture data, converting a Jest file to Vitest. They are poor at deciding what matters. Write the first test of each pattern yourself so the generated ones inherit good queries and meaningful assertions, and always review the assertions rather than the syntax.

Wrapping up

You now have a complete automated testing setup for a React project: fast unit tests, behaviour-driven component tests with realistic network mocking, cross-browser end-to-end coverage of your critical journeys, coverage thresholds that block regressions, and a CI pipeline that enforces all of it on every pull request. Start with the form and the list from this guide, then add one test with every bug fix. Within a few sprints your suite becomes the reason you ship on Friday afternoon without worrying.

Need help retrofitting this stack onto a large legacy React codebase, or reducing a flaky pipeline to something your team trusts again? That is exactly the kind of work we do at coding4.net, so get in touch.

Leave a Comment

Your email address will not be published. Required fields are marked *