testing-react-ts
React/TypeScript Testing
1. Philosophy
Core Principles
- Trustworthiness > coverage — A small suite you trust beats a large suite full of false confidence. Every test should catch a real bug if one existed.
- 5 good Playwright e2e tests > 100 component tests with heavy mocking — End-to-end tests exercise the real system. Component tests with mocked-out everything prove very little.
- Pareto principle — Write the fewest tests that cover 80% of what matters. Focus on critical user paths and known-fragile areas.
- Component tests for interaction logic — Use component tests where they shine: verifying that clicks, keyboard input, and conditional rendering work correctly.
- Real over mocked — MSW over module mocks. Rendered over shallow. Real stores over fake ones. The closer a test is to production, the more it proves.
- Test behavior, not implementation — Assert what the user sees and experiences, not internal state, CSS classes, or component structure.
2. Testing Pyramid
| Level | Tool | Location | Purpose |
|---|---|---|---|
| Component tests | Vitest + RTL | src/**/__tests__/*.test.tsx |
Component rendering, interactions |
| Integration tests | Vitest + MSW | src/**/*.integration.test.tsx |
Feature workflows, store + API |
| E2e tests | Playwright | src/test/ui/*.spec.ts |
Full user journeys |
Commands
pnpm test:unit # Run all Vitest tests
pnpm test:ui:headless # Run Playwright tests headless
pnpm test:all # Run both Vitest and Playwright
pnpm test:unit --watch # TDD mode — re-runs on file change
3. Test Planning
Before writing any test code, plan first. This prevents wasted effort and ensures coverage of what matters.
- List all potential test cases for the feature or component
- Categorize each case as critical, medium, or small importance
- Discard small-importance cases — they cost time without meaningful protection
- Write remaining cases in plain text first — describe expected behavior in sentences
- Then write test code — translate the plain-text cases into executable tests
Example planning output:
Feature: Login form
Critical:
- Submits credentials and redirects to dashboard on success
- Displays server error message on 401
- Displays field-level errors from API validation response
Medium:
- Disables submit button while request is in flight
- Shows loading spinner during submission
Discarded (small):
- Input field focus styles
- Placeholder text content
4. Component Tests (Vitest + React Testing Library)
import { render, screen } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { Button } from '../button';
describe('Button', () => {
it('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('handles click events', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('can be disabled', () => {
render(<Button disabled>Disabled button</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Note: Always call userEvent.setup() at the top of the test, then use user.click(), user.type(), etc. Do not call userEvent.click() directly — the setup pattern enables proper event sequencing.
Key Practices
- Use custom render from
test-utils— it wraps components with all necessary providers (theme, router, store). Never call@testing-library/react'srenderdirectly. - Semantic queries first —
getByRole,getByLabelText,getByText. These reflect what users and assistive technology see. AvoidgetByTestIdunless no semantic alternative exists. userEventoverfireEvent—userEventsimulates realistic browser interactions (focus, hover, keystrokes).fireEventdispatches synthetic events that skip browser behavior.- Test user-visible behavior — Assert on rendered text, ARIA roles, visibility, and navigation. Do not assert on internal state, hook return values, or CSS classes.
- One concept per test — Each
itblock should test a single behavior. If a test needs a long description, it is testing too much.
Query Priority
| Priority | Query | When to use |
|---|---|---|
| 1 | getByRole |
Buttons, headings, links, form controls |
| 2 | getByLabelText |
Form inputs with associated labels |
| 3 | getByPlaceholderText |
Inputs without visible labels (rare) |
| 4 | getByText |
Non-interactive content |
| 5 | getByTestId |
Last resort — no semantic alternative |
Async Patterns
// Wait for element to appear after async operation
await screen.findByText('Success');
// Wait for element to disappear
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
// Assert element is NOT present (use query, not get)
expect(screen.queryByText('Error')).not.toBeInTheDocument();
5. Test Utilities Setup
// src/test/test-utils.tsx
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { ThemeProvider } from 'next-themes';
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
return (
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
enableSystem={false}
>
{children}
</ThemeProvider>
);
};
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllTheProviders, ...options });
// Re-export everything from RTL, override render
export * from '@testing-library/react';
export { customRender as render };
Key Details
Omit<RenderOptions, 'wrapper'>— prevents callers from accidentally overriding the wrapper.export *— re-exportsscreen,waitFor,fireEvent, etc. so tests only need one import:import { render, screen } from '@/test/test-utils'.- Adding providers — when the app adds new context providers, update
AllTheProviders. All component tests inherit the change automatically.
Setup File (src/test/setup.ts)
The setup file runs before every test. It handles:
- Environment variables — set
process.envvalues before any imports so t3-env validation passes - Jest-DOM matchers —
import '@testing-library/jest-dom'adds.toBeInTheDocument(),.toBeVisible(), etc. - Next.js mocks —
vi.mock('next/navigation'),vi.mock('next/link'),vi.mock('next/image')since these rely on Next.js internals unavailable in jsdom - Browser API polyfills —
matchMedia,IntersectionObserver,ResizeObserverfor components that use them - Auth store mocks — pre-configured authenticated state for component tests
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true, // vi, describe, it, expect available globally
exclude: ['**/node_modules/**', '**/ui/**'], // Exclude Playwright tests
env: {
NEXT_PUBLIC_API_URL: 'http://localhost:3001',
NEXT_PUBLIC_TEST_MODE: 'true',
NODE_ENV: 'test'
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src') // Match Next.js path alias
}
}
});
Key points:
globals: true— makesvi,describe,it,expectavailable without imports. Tests can still explicitly import fromvitestif preferred.exclude: ['**/ui/**']— prevents Vitest from picking up Playwright spec files.@alias — matches the Next.js@/path alias so imports like@/test/test-utilsresolve correctly.
6. Store Testing
Stores are tested by calling actions and checking resulting state. Use real store instances, not mocks.
import { http, HttpResponse } from 'msw';
import { useItemStore } from '@/stores/item-store';
// Reset store to a known initial state before each test
const initialState = { data: null, loading: false, error: null };
describe('itemStore', () => {
beforeEach(() => {
useItemStore.setState(initialState);
});
it('fetches items successfully', async () => {
// MSW handler returns mock data at network level
await useItemStore.getState().fetchItems();
const state = useItemStore.getState();
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
expect(state.data).toHaveLength(3);
});
it('handles API failure with Result pattern', async () => {
// Override the default MSW handler for this test
server.use(
http.get('/api/items', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 })
)
);
await useItemStore.getState().fetchItems();
const state = useItemStore.getState();
expect(state.loading).toBe(false);
expect(state.error).not.toBeNull();
expect(state.data).toBeNull();
});
});
Rules
- Reset store state in
beforeEachto prevent test pollution - Use MSW to intercept network requests — this tests the real fetch, validation, and store flow
- Verify both success and error paths — the Result pattern means both branches must be covered
- Do not mock the store itself — test the real implementation
7. E2E Tests (Playwright)
import { test, expect } from '@playwright/test';
test('should display dashboard layout when authenticated', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/.*\/dashboard\/hello-world$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const sidebarToggle = page.getByRole('button', { name: /toggle sidebar/i });
await expect(sidebarToggle).toBeVisible();
await sidebarToggle.click();
await expect(sidebarToggle).toBeVisible();
});
Playwright Projects Architecture
The config defines three Playwright projects that control authentication state:
| Project | testMatch pattern |
Auth state | Purpose |
|---|---|---|---|
global setup |
global.setup.ts |
Creates auth cookies, saves storageState |
Runs first |
authenticated |
*authenticated*.spec.ts |
Uses saved storageState |
Dashboard, protected pages |
auth-less |
*.spec.ts (excluding authenticated) |
No stored state | Public pages, login flow |
src/test/ui/
├── global.setup.ts # Sets auth cookies, saves storageState
├── utils/ # Shared test utilities (e.g., hydration checker)
├── dashboard.authenticated.spec.ts # Authenticated: dashboard tests
├── dashboard.routing.authenticated.spec.ts # Authenticated: routing tests
├── public-pages.spec.ts # Auth-less: public page tests
└── ui-quality.spec.ts # Auth-less: accessibility, visual checks
global.setup.tsruns before all other projects. It sets authentication cookies and saves browserstorageStateto.test-data/mock-auth/user.json. Bothauthenticatedandauth-lessprojects depend onglobal setup.*.authenticated.spec.tsfiles automatically load the savedstorageState, so each test starts with an authenticated session.- Other
*.spec.tsfiles run without stored auth state, testing public-facing pages and login flows. webServerconfig builds the Next.js app and starts it on a test port (pnpm build && PORT=3100 pnpm start).
Playwright Best Practices
- Use locators, not selectors —
page.getByRole('button', { name: 'Submit' })overpage.locator('.submit-btn') - Auto-waiting — Playwright waits for elements automatically. Do not add manual
waitForTimeoutcalls. - Assertions with
expect— Use Playwright'sexpect(not Vitest's). It has built-in retrying:await expect(page.getByText('Done')).toBeVisible(). - Isolate tests — Each test should be independent. Do not rely on state from a previous test.
8. Mocking Strategy
Preferred: MSW (Mock Service Worker)
MSW intercepts requests at the network level. This means your code's real fetch calls, Zod validation, and store logic all execute — only the server response is faked.
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Override handlers per-test for error scenarios:
it('shows error state on API failure', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
})
);
render(<UserList />);
await screen.findByText('Not found');
});
Acceptable: Direct store state or hook mocks
For isolated component tests where you only care about rendering logic, pre-setting store state is acceptable:
useUserStore.setState({ data: mockUser, loading: false, error: null });
render(<UserProfile />);
For components that depend on hooks returning specific data, mock the hook module and use vi.mocked():
vi.mock('@/hooks/use-current-page', () => ({
useCurrentPage: vi.fn()
}));
import { useCurrentPage } from '@/hooks/use-current-page';
beforeEach(() => {
vi.mocked(useCurrentPage).mockReturnValue({
title: 'Dashboard',
url: '/dashboard',
icon: 'dashboard'
});
});
This is acceptable when the hook returns derived/contextual data. Avoid this for hooks that perform side effects or API calls — use MSW instead.
Avoid
| Pattern | Why |
|---|---|
Mocking internal modules (vi.mock('./utils')) |
Couples tests to file structure; breaks on refactors |
| Shallow rendering | Tests nothing meaningful; misses integration bugs |
Mocking React hooks (vi.mock('react', ...)) |
Fragile, tests implementation, not behavior |
| Mocking the store's action implementations | Defeats the purpose of testing the store flow |
9. File Organization
src/test/
├── setup.ts # Global config: env vars, Next.js mocks, browser polyfills
├── test-utils.tsx # Custom render with all providers
└── ui/ # Playwright tests
├── global.setup.ts # Auth state creation (cookies + storageState)
├── utils/ # Shared Playwright utilities
├── dashboard.authenticated.spec.ts # Authenticated user journeys
├── public-pages.spec.ts # Public page tests (no auth)
└── ui-quality.spec.ts # Accessibility and visual checks
src/{feature}/__tests__/
├── FeatureComponent.test.tsx # Component test
└── feature.integration.test.tsx # Integration test (store + API via MSW)
Naming Conventions
| File type | Pattern | Example |
|---|---|---|
| Component test | ComponentName.test.tsx |
LoginForm.test.tsx |
| Integration test | feature.integration.test.tsx |
auth.integration.test.tsx |
| E2e test (authenticated) | feature.authenticated.spec.ts |
dashboard.authenticated.spec.ts |
| E2e test (public) | feature.spec.ts |
public-pages.spec.ts, ui-quality.spec.ts |
| Test utilities | test-utils.tsx |
src/test/test-utils.tsx |
10. Test Validation Checklist
Before committing tests, verify every item:
- Tests are meaningful — each test would catch a real bug if one were introduced
- Tests don't compromise source code — no
exportadded solely for testing; nodata-testidadded when a semantic query works - Error paths are tested — not just happy paths; Result error branches, network failures, validation errors
- Async operations properly awaited — no floating promises; use
awaitonuserEvent,findByqueries, and store actions - No implementation details tested — no assertions on internal state, CSS classes, component instance methods, or hook return values
- Tests are independent — each test can run in isolation;
beforeEachresets shared state - No test-only code in production — no
if (process.env.NODE_ENV === 'test')branches in source code
More from quick-brown-foxxx/coding_rules_ts
managing-state
State management patterns: Zustand stores, React Context, URL state, form state. Use when creating stores, managing component state, or choosing state patterns. ALWAYS load this skill when writing any react code!
2writing-react-ts-code
Core React/TypeScript standards: strict typing, Result-based error handling, component patterns, code style. Use when writing or editing any React/TypeScript code. ALWAYS load this skill before working with any react code.
2building-ui-components
UI component patterns: shadcn/ui, component organization, variant system. Use when building or modifying React UI components. ALWAYS load this skill when working with react code.
2