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. 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 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)










