Why Your React App Needs a Real Testing Strategy in 2026
Most teams write tests reactively — after a bug slips through to production, someone adds a test and moves on. That approach doesn't scale. As your React app grows across features, contributors, and deployment environments, an untested component library becomes a liability.
Vitest has emerged as the go-to test runner for React projects in 2026 — particularly those using Vite as their build tool. It is fast, ESM-native, and shares configuration with your existing Vite setup. Paired with React Testing Library, you get a workflow that tests components the way users actually interact with them, rather than testing implementation details.
This guide walks you through building a testing strategy from scratch — from setup to writing meaningful tests for real-world component patterns.
What You'll Need
- A React project using Vite (React 18+ or 19)
- Node.js 20 or higher
- Basic familiarity with React components and props
- npm, pnpm, or yarn as your package manager
If you are starting fresh, scaffold a new project with npm create vite@latest my-app -- --template react-ts before continuing.
Step 1: Install Vitest and Testing Dependencies
Open your terminal in the project root and install the required packages:
npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-eventHere is what each package does:
- vitest — the test runner and assertion library
- @vitest/ui — an optional browser-based test dashboard (highly recommended)
- jsdom — simulates a browser DOM environment inside Node
- @testing-library/react — renders components and exposes user-facing queries
- @testing-library/jest-dom — adds readable matchers like
toBeInTheDocument() - @testing-library/user-event — simulates real user interactions like typing and clicking
Step 2: Configure Vitest in Your Vite Config
Open vite.config.ts and add the test configuration block:
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'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
})The globals: true option means you can use describe, it, and expect without importing them in every file — a small but meaningful quality-of-life improvement.
Create the Setup File
Create the directory and file at src/test/setup.ts:
import '@testing-library/jest-dom'This imports the custom matchers globally so every test file has access to them automatically.
Step 3: Add Test Scripts to package.json
Update your package.json scripts section:
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}Use npm test during development for watch mode. Use npm run test:run in your CI pipeline, and npm run test:ui when you want the visual dashboard to debug failing tests interactively.
Step 4: Write Your First Component Test
Let's test a real-world component — a Button that accepts a label, a variant, and an onClick handler.
The Component
// src/components/Button.tsx
type ButtonProps = {
label: string
variant?: 'primary' | 'secondary'
onClick?: () => void
disabled?: boolean
}
export function Button({ label, variant = 'primary', onClick, disabled }: ButtonProps) {
return (
<button
className={`btn btn--${variant}`}
onClick={onClick}
disabled={disabled}
aria-disabled={disabled}
>
{label}
</button>
)
}The Test File
// src/components/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders the label text', () => {
render(<Button label="Submit" />)
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument()
})
it('calls onClick when clicked', async () => {
const handleClick = vi.fn()
render(<Button label="Submit" onClick={handleClick} />)
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(handleClick).toHaveBeenCalledOnce()
})
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn()
render(<Button label="Submit" onClick={handleClick} disabled />)
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(handleClick).not.toHaveBeenCalled()
})
it('applies the correct variant class', () => {
render(<Button label="Cancel" variant="secondary" />)
expect(screen.getByRole('button')).toHaveClass('btn--secondary')
})
})Pro tip: Always query by role and accessible name first — getByRole('button', { name: /submit/i }). This mirrors how assistive technologies navigate your UI, which means your tests double as accessibility checks.
Step 5: Test Async Behaviour and API-Dependent Components
Many components fetch data or respond to async state changes. Here is a pattern for testing a component that loads user data from an API.
// src/components/UserCard.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserCard } from './UserCard'
global.fetch = vi.fn()
describe('UserCard', () => {
it('displays user name after loading', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ name: 'Aisha Tan', role: 'Designer' }),
})
render(<UserCard userId="123" />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Aisha Tan')).toBeInTheDocument()
})
})
})Notice that we are mocking fetch at the module level using vi.fn() — Vitest's built-in mock utility. This keeps tests deterministic and fast without hitting real endpoints.
Common pitfall: Forgetting to clear mocks between tests. Add vi.clearAllMocks() in your setup file or set clearMocks: true in your Vitest config to avoid state leaking between test cases.
Step 6: Organise Your Tests for Scale
As your project grows, test organisation becomes critical. At Lenka Studio, we follow a co-location pattern — placing test files directly beside the component they cover:
src/
components/
Button/
Button.tsx
Button.test.tsx
Button.stories.tsx ← Storybook story (optional)
pages/
Dashboard/
Dashboard.tsx
Dashboard.test.tsx
hooks/
useAuth.ts
useAuth.test.tsThis pattern makes it immediately obvious when a component lacks test coverage and keeps related files together during refactors.
Define Coverage Thresholds
Add minimum thresholds to your Vitest config to enforce coverage standards in CI:
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
}A hard rule: 100% coverage is not the goal. Aim for 80% on lines and functions, and spend your energy testing user-facing behaviour and edge cases — not implementation internals.
Step 7: Integrate Tests Into Your CI Pipeline
Tests that only run locally are not a testing strategy — they are a suggestion. Add a test step to your GitHub Actions workflow:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:run
- run: npm run test:coverageThis runs on every push and every pull request, catching regressions before they reach your main branch. If your team deploys to Vercel, Netlify, or Cloudflare Pages, these CI checks can gate your preview deployments automatically.
Common Pitfalls to Avoid
- Testing implementation details: Avoid querying by CSS class names, component state, or internal method calls. Test what the user sees and does.
- Overusing
act()warnings: If you seeact()warnings, wrap async state updates inwaitFor()rather than manually wrapping inact(). - Slow tests from real timers: Use
vi.useFakeTimers()for components that depend onsetTimeoutorsetInterval. - Skipping edge cases: Always test the empty state, error state, and loading state — not just the happy path. These are the states most likely to break in production.
Next Steps
You now have a working, scalable React testing setup with Vitest and Testing Library — complete with async testing patterns, CI integration, and a project structure that grows with your codebase.
From here, consider these additions to strengthen your strategy:
- Add Storybook interaction tests using
@storybook/testto test components visually and programmatically in the same workflow - Explore Playwright for end-to-end tests that validate full user journeys across pages
- Set up snapshot testing sparingly for design-critical components to catch unintended visual regressions
If your team is building a React app and you want a development workflow that is built to scale — including testing, CI/CD, architecture, and deployment — the team at Lenka Studio works with SMBs across Australia, Singapore, Canada, and the US to ship products that hold up under real-world load. Get in touch to talk through your project.




