
Your components work when you click around. You’ve tested the happy path in the browser a dozen times, the form submits, the modal closes, the protected route redirects when it should. Then someone on your team refactors a useEffect, ships it on a Friday, and the "obviously fine" login flow breaks in production over the weekend. The test suite — if it existed — would have caught it before the PR merged. If it didn't exist, you just found out the hard way why it should.
I’ve rebuilt this setup on enough projects now that I’ve got it down to a repeatable pattern: Vitest for the runner, React Testing Library (RTL) for rendering and querying, MSW for network mocking, and a GitHub Actions job that blocks merges when any of it goes red. This is that setup, end to end, with the parts people actually get wrong — hooks in isolation, RBAC-gated UI, and knowing when a snapshot test is pulling its weight versus just generating noise in review.

Setting up Vitest + React Testing Library in a Vite/CRA Project
If you’re on Vite, Vitest is close to zero-config since it reuses your existing Vite config. If you’re still on Create React App, you’ll want to either migrate to Vite (worth doing anyway) or wire Vitest in alongside your existing Jest-based react-scripts test — I'll show the Vite path since that's where most new projects land in 2026.
Install the core packages:
npm install -D vitest @vitejs/plugin-react jsdom \
@testing-library/react @testing-library/jest-dom @testing-library/user-event \
msw @vitest/coverage-v8
Create vitest.config.ts at your project root:
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
// jsdom simulates a browser DOM in Node so RTL can render into it
environment: 'jsdom',
// Lets you use expect/describe/it globally without importing them everywhere
globals: true,
// Runs before every test file — sets up jest-dom matchers, MSW server, etc.
setupFiles: ['./src/test/setup.ts'],
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['**/*.stories.tsx', '**/*.d.ts', 'src/test/**'],
},
},
})
);
And the shared setup file that runs before every test:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';
// Start the MSW mock server before all tests, reset handlers after each
// test so one test's mock doesn't leak into the next, and close it when done.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
cleanup(); // unmounts anything RTL rendered so tests stay isolated
});
afterAll(() => server.close());
Add scripts to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}One gotcha: if your vite.config.ts doesn't already include @vitejs/plugin-react, add it — Vitest needs it to transform JSX/TSX in test files, not just your app source.
Testing Components by Behavior, Not Implementation Detail

The single biggest mistake I see in React test suites is querying by CSS class or component internals instead of what the user actually sees and does. That couples your tests to your markup, so a harmless div-to-span refactor breaks fifteen tests that had nothing to do with the change. RTL’s philosophy is baked into its API for a reason: query by role, label, and text — the same way a screen reader or a user would find things.
Here’s a simple form component we’ll test:
// src/components/LoginForm.tsx
import { useState, FormEvent } from 'react';
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(''); function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!email.includes('@')) {
setError('Enter a valid email address');
return;
}
setError('');
onSubmit(email, password);
}return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p role="alert">{error}</p>}<button type="submit">Log in</button>
</form>
);
}
And the test, written the way a user would interact with it:
// 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 email and password', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);// Query by accessible role/label — not by class name or test id
await user.type(screen.getByLabelText(/email/i), 'chirag@example.com');
await user.type(screen.getByLabelText(/password/i), 'hunter2');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(handleSubmit).toHaveBeenCalledWith('chirag@example.com', 'hunter2');
}); it('shows a validation error for an invalid email and does not submit', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(screen.getByRole('alert')).toHaveTextContent(/valid email/i);
expect(handleSubmit).not.toHaveBeenCalled();
});
});Notice there’s no data-testid anywhere and no reaching into component state. If you rename the internal error state variable or swap useState for a reducer tomorrow, this test doesn't care — it only cares that a real user gets the outcome they expect. Reach for getByTestId as a last resort, not a first instinct; it's a signal that the element has no meaningful accessible role, which is worth fixing on its own.
Testing Custom Hooks in Isolation (renderHook)
Hooks that hold non-trivial logic — debouncing, pagination, form state, polling — deserve their own tests instead of being exercised indirectly through whatever component happens to use them. renderHook from RTL gives you a hook running inside a real React tree without needing a component to wrap it.
Here’s a common one — a hook that debounces a value:
// src/hooks/useDebouncedValue.ts
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value); useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);return debounced;
}
Testing it with fake timers so we don’t actually wait 300ms per test:
// src/hooks/useDebouncedValue.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useDebouncedValue } from './useDebouncedValue';
describe('useDebouncedValue', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers()); it('returns the initial value immediately', () => {
const { result } = renderHook(() => useDebouncedValue('a', 300));
expect(result.current).toBe('a');
}); it('updates only after the delay has passed', () => {
// rerender lets us change the props passed into the hook between renders
const { result, rerender } = renderHook(
({ value, delay }) => useDebouncedValue(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
); rerender({ value: 'ab', delay: 300 });// Still the old value — the debounce window hasn't elapsed yet
expect(result.current).toBe('a');
// act() flushes the effect's setTimeout callback synchronously
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('ab');
}); it('resets the timer if the value changes again before the delay elapses', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'a' } }
); rerender({ value: 'ab' });
act(() => vi.advanceTimersByTime(200));
rerender({ value: 'abc' }); // resets the debounce clockact(() => vi.advanceTimersByTime(200));
expect(result.current).toBe('a'); // only 200ms since the last change
act(() => vi.advanceTimersByTime(100));
expect(result.current).toBe('abc'); // now 300ms have passed
});
});
The pattern to remember: any state update triggered outside of a user event (a setTimeout, a promise resolving, a subscription firing) needs to happen inside act(), or React will warn you that state updates weren't wrapped and your assertions may run before the DOM settles.
Mocking API Calls
Don’t mock fetch or axios directly with vi.fn() — it works for one test but falls apart the moment the component makes more than one call, or you want to reuse the same mock across a suite. Mock Service Worker (MSW) intercepts requests at the network layer, so your component code runs exactly as it would in production — it just never leaves the process.
Set up a reusable server with handlers:
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Chirag Mehta',
role: 'admin',
});
}),
http.get('/api/users/999', () => {
return HttpResponse.json({ message: 'User not found' }, { status: 404 });
}),
];// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
This server is the same one wired into src/test/setup.ts earlier, so it's live for every test file automatically. Now a component that fetches on mount:
// src/components/UserProfile.tsx
import { useEffect, useState } from 'react';
interface User {
id: string;
name: string;
role: string;
}export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState(''); useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error('not found');
return res.json();
})
.then((data) => !cancelled && setUser(data))
.catch(() => !cancelled && setError('Could not load user')); return () => {
cancelled = true;
};
}, [userId]); if (error) return <p role="alert">{error}</p>;
if (!user) return <p>Loading...</p>;return (
<div>
<h2>{user.name}</h2>
<p>Role: {user.role}</p>
</div>
);
}
And the test — including overriding a handler per-test for the error path:
// src/components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
import { UserProfile } from './UserProfile';
describe('UserProfile', () => {
it('renders the user once the fetch resolves', async () => {
render(<UserProfile userId="1" />);expect(screen.getByText(/loading/i)).toBeInTheDocument();
// findBy* queries wait for the element to appear — ideal for async UI
expect(await screen.findByText('Chirag Mehta')).toBeInTheDocument();
expect(screen.getByText(/role: admin/i)).toBeInTheDocument();
});
it('shows an error message when the request fails', async () => {
// Override the default handler for just this test
server.use(
http.get('/api/users/1', () => {
return HttpResponse.json({ message: 'Server error' }, { status: 500 });
})
);render(<UserProfile userId="1" />);
expect(await screen.findByRole('alert')).toHaveTextContent(/could not load user/i);
});
});Because server.resetHandlers() runs in afterEach, that override never bleeds into the next test.
Testing Protected Routes and RBAC-Gated Components

RBAC bugs are the ones that actually hurt — a viewer seeing an admin panel isn’t a cosmetic glitch, it’s a security incident. These deserve explicit tests per role, not just “logged in vs. logged out.”
Assume a useAuth hook and a gate component like this:
// src/components/RequireRole.tsx
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
interface RequireRoleProps {
allowed: Array<'admin' | 'editor' | 'viewer'>;
children: ReactNode;
}export function RequireRole({ allowed, children }: RequireRoleProps) {
const { user } = useAuth();if (!user) return <Navigate to="/login" replace />;
if (!allowed.includes(user.role)) return <Navigate to="/403" replace />;
return <>{children}</>;
}To test this properly, render it inside a real router with a memory history so <Navigate> actually produces observable output, and mock useAuth per test case:
// src/components/RequireRole.test.tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { describe, it, expect, vi } from 'vitest';
import { RequireRole } from './RequireRole';
import { useAuth } from '../hooks/useAuth';
vi.mock('../hooks/useAuth');
const mockUseAuth = vi.mocked(useAuth);function renderWithRouter(initialRoute: string) {
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<Routes>
<Route
path="/admin"
element={
<RequireRole allowed={['admin']}>
<p>Admin dashboard</p>
</RequireRole>
}
/>
<Route path="/login" element={<p>Please log in</p>} />
<Route path="/403" element={<p>Forbidden</p>} />
</Routes>
</MemoryRouter>
);
}describe('RequireRole', () => {
it('renders protected content for a user with an allowed role', () => {
mockUseAuth.mockReturnValue({ user: { id: '1', role: 'admin' } });
renderWithRouter('/admin');
expect(screen.getByText('Admin dashboard')).toBeInTheDocument();
}); it('redirects to /403 for a logged-in user with the wrong role', () => {
mockUseAuth.mockReturnValue({ user: { id: '2', role: 'viewer' } });
renderWithRouter('/admin');
expect(screen.getByText('Forbidden')).toBeInTheDocument();
expect(screen.queryByText('Admin dashboard')).not.toBeInTheDocument();
}); it('redirects to /login when there is no user', () => {
mockUseAuth.mockReturnValue({ user: null });
renderWithRouter('/admin');
expect(screen.getByText('Please log in')).toBeInTheDocument();
});
});vi.mock('../hooks/useAuth') hoists to the top of the file automatically, which is exactly what you want — it needs to intercept the import before RequireRole pulls it in. Run one test per role that matters (admin, editor, viewer, unauthenticated) rather than one test with a loop asserting all of them — when one fails, you want the test name to tell you which role broke without reading the assertion.
Snapshot Tests: When They Help and When They Don’t
Snapshot tests get a bad reputation because they’re overused on things that change constantly and get “updated” by reflex (vitest -u) without anyone actually reading the diff. That defeats the entire purpose — a snapshot only protects you if a failing snapshot triggers a human to look at what changed.
Where they’re genuinely useful: components with stable, non-trivial output that you want to catch accidental changes to — a rendered error boundary, a formatted currency/date utility’s output, a design-system primitive that shouldn’t drift.
// src/components/PriceTag.test.tsx
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { PriceTag } from './PriceTag';
describe('PriceTag', () => {
it('matches the snapshot for a discounted price', () => {
const { container } = render(
<PriceTag amount={4999} currency="USD" discountPercent={20} />
);
expect(container).toMatchSnapshot();
});
});Where they actively hurt: full-page components, anything with dynamic data (timestamps, IDs), and anything under active development. A 200-line snapshot diff in a PR review gets skimmed and approved, not read — it’s noise dressed up as coverage. My rule of thumb: if I can’t imagine reading the diff line-by-line when it fails, I don’t write the snapshot. I’d rather write two or three explicit getByText/toHaveAttribute assertions that say exactly what I'm protecting.
If you do use them, keep them small and scoped — snapshot a single element or a formatted string, not container on a whole page:
it('formats large numbers with a thousands separator', () => {
expect(formatCurrency(1234567, 'USD')).toMatchInlineSnapshot('"$1,234,567.00"');
});Inline snapshots are worth preferring over file snapshots for exactly this reason — the expected value sits right in the test, so a reviewer sees the “before” and “after” in the same diff hunk instead of jumping to a .snap file.
Adding Component Tests to a GitHub Actions Pipeline
Tests that only run on your machine catch bugs only you would have caught anyway. Wire this into CI so a broken suite blocks the merge, not the release.
# .github/workflows/test.yml
name: Test
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npx tsc --noEmit
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 14
Two things worth calling out. First, npm ci instead of npm install — it installs exactly what's in the lockfile, so CI can't silently pass with a different dependency tree than what your teammates have locally. Second, if: always() on the coverage upload means you still get the report even when tests fail, which is often when you most want to see which lines went untested.
If you want to block merges on a coverage floor rather than just “did it fail,” add thresholds directly in vitest.config.ts:
coverage: {
provider: 'v8',
thresholds: {
lines: 75,
functions: 75,
branches: 65,
statements: 75,
},
},vitest run exits non-zero automatically if any threshold isn't met, so the CI step above fails on its own — no extra scripting needed. Set the branch protection rule in GitHub to require this check before merge, and the whole loop closes: broken behavior gets caught in the PR, not in a Sunday-night incident channel.
Wrapping Up
You now have a full loop: Vitest running fast in jsdom, RTL forcing you to test what users actually experience instead of your component internals, renderHook for logic that lives outside JSX, MSW standing in for your API without touching fetch directly, explicit per-role tests for anything RBAC-gated, and a CI job that won't let any of it regress silently.
Next, go audit an existing component in your codebase that has zero tests and has broken in production before — that’s your highest-leverage target, not a brand-new component nobody’s touched yet. Add one behavior test, one hook test if it has custom logic, and one MSW-backed test if it hits an API. Then look at your RequireRole (or equivalent) gate and make sure every role in your system has an explicit test, not just "logged in" and "logged out" — that's usually where the real gaps are hiding.
