TypeScript/React testing
This project uses Vitest as the test runner. The Next.js 16 official documentation recommends Vitest over Jest for App Router projects: native ESM and TypeScript support require no additional transformation configuration, and Vitest runs 3–5x faster than Jest on equivalent suites. Do not introduce Jest.
Test runner commands
npm test # run all tests once
npm run test:watch # watch mode
npm run test:coverage # with coverage report
# Targeted runs
npx vitest run src/components/invoice/ # run tests in a directory
npx vitest run -t "renders the client" # filter by test name pattern
npx vitest run path/to/component.test.tsx # run a single file
Decision framework
Before writing any test, classify it:
| Category | What it covers | Vitest environment |
|---|---|---|
| Unit | Pure functions, utilities, Zod schemas, computed logic | node |
| Component | Client Components rendered with RTL | jsdom |
| Hook | Custom hooks via renderHook |
jsdom |
| Server Action | 'use server' functions with mocked Prisma |
node |
| Integration | Real database or external service | node + env gate |
jsdom is the default environment configured in vitest.config.ts. For Server Actions and utilities, add a file-level directive to switch to node:
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest';
Pick the lightest category that validates the behavior. Async Server Components (RSCs) cannot be rendered by RTL; see "RSC testing strategy" below and references/rsc-patterns.md for full mocking recipes.
File organization
- One test file per source file:
invoice-card.tsxmaps toinvoice-card.test.tsx. - Co-locate test files next to the source file, not in a separate
__tests__/directory. - Fixture factories live in
src/__fixtures__/<domain>.fixtures.ts. - All new features require tests; every bug fix requires a regression test.
Canonical test structure
Every test file follows the Arrange/Act/Assert pattern separated by blank lines. Do NOT write // Arrange, // Act, or // Assert comments. Tests read like a specification.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InvoiceCard } from './invoice-card';
import { buildInvoice } from '@/__fixtures__/invoice.fixtures';
describe('InvoiceCard', () => {
it('renders the client name and formatted amount', () => {
const invoice = buildInvoice({ clientName: 'Acme Corp', amount: 1500 });
render(<InvoiceCard invoice={invoice} />);
expect(screen.getByRole('heading', { name: 'Acme Corp' })).toBeInTheDocument();
expect(screen.getByText('$1,500.00')).toBeInTheDocument();
});
it('calls onMarkPaid with the invoice id when the mark-paid button is clicked', async () => {
const user = userEvent.setup();
const
const invoice = buildInvoice({ status: 'unpaid' });
render(<InvoiceCard invoice={invoice} />);
await user.click(screen.getByRole('button', { name: /mark paid/i }));
expect(onMarkPaid).toHaveBeenCalledExactlyOnceWith(invoice.id);
});
it('disables the mark-paid button when isPending is true', () => {
const invoice = buildInvoice({ status: 'unpaid' });
render(<InvoiceCard invoice={invoice} isPending />);
expect(screen.getByRole('button', { name: /mark paid/i })).toBeDisabled();
});
});
Structural rules:
describenames the component or function under test. Nestdescribeblocks to scope a scenario or method.itreads as a complete sentence:it('disables the submit button while the mutation is pending').- Each
itblock covers one logical scenario. Split complex assertions into focused tests.
RTL query priority
Use queries in accessibility-first order:
getByRole(first choice; exercises semantic HTML)getByLabelText(for labeled form inputs)getByPlaceholderText(fallback for unlabeled inputs)getByText(for non-interactive text content)getByTestId(last resort; adddata-testidonly when no semantic query fits)
// ✅ Accessibility-first
screen.getByRole('button', { name: /save invoice/i });
screen.getByLabelText('Invoice amount');
screen.getByRole('alert');
screen.getByRole('combobox', { name: 'Status' });
// ❌ Coupled to implementation details
screen.getByTestId('save-btn');
container.querySelector('.invoice-form');
User interactions
Always use @testing-library/user-event, not fireEvent. user-event simulates the full browser event sequence (pointerdown, focus, input, keydown, keyup, click). fireEvent dispatches a single synthetic event and misses intermediary behavior that real components react to.
const user = userEvent.setup();
await user.type(screen.getByLabelText('Client name'), 'Acme Corp');
await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'paid');
await user.click(screen.getByRole('button', { name: /save/i }));
await user.keyboard('{Escape}');
await user.clear(screen.getByRole('textbox', { name: 'Amount' }));
Parameterized tests
Use describe.each or it.each whenever multiple inputs share the same execution logic. Never loop with .forEach inside a single it block.
// ✅ Parameterized: each case gets its own entry in the test report
it.each([
{ status: 'paid', label: 'Paid', expectedClass: 'bg-green-100' },
{ status: 'overdue', label: 'Overdue', expectedClass: 'bg-red-100' },
{ status: 'pending', label: 'Pending', expectedClass: 'bg-yellow-100' },
{ status: 'draft', label: 'Draft', expectedClass: 'bg-muted' },
])('renders "$label" badge with the correct color for $status status', ({ status, label, expectedClass }) => {
render(<StatusBadge status={status} />);
const badge = screen.getByRole('status');
expect(badge).toHaveTextContent(label);
expect(badge).toHaveClass(expectedClass);
});
// ✅ describe.each when each group needs multiple it blocks
describe.each([
{ dueDate: new Date('2025-01-01'), expectedStatus: 'overdue' },
{ dueDate: new Date('2030-01-01'), expectedStatus: 'upcoming' },
])('invoice due $dueDate', ({ dueDate, expectedStatus }) => {
it('displays the correct status badge', () => { ... });
it('sorts before invoices due later', () => { ... });
});
// ❌ forEach hides failures and conflates scenarios
it('handles all badge statuses', () => {
['paid', 'overdue', 'pending'].forEach((status) => {
render(<StatusBadge status={status} />);
// ...
});
});
Fixture factories
Build domain objects with factory functions, not inline literals. Inline literals couple tests to schema shape and break silently when fields are added or renamed.
// src/__fixtures__/invoice.fixtures.ts
import type { Invoice } from '@/types/invoice.types';
export function buildInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
id: 'inv_test_001',
clientName: 'Test Client',
amount: 1000,
currency: 'USD',
status: 'unpaid',
dueDate: new Date('2026-12-31'),
createdAt: new Date('2026-01-01'),
userId: 'user_test_001',
...overrides, // must be last so callers can override any field
};
}
Co-locate factories with the domain type. One factory per domain entity. Name them build<Entity>.
Server Action testing
Call the Server Action function directly. Mock the Prisma client at the module boundary.
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { markInvoicePaid } from '@/lib/actions/invoice-actions';
vi.mock('@/lib/db', () => ({
db: {
invoice: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock('next/cache', () => ({
revalidateTag: vi.fn(),
}));
describe('markInvoicePaid', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns success and revalidates the invoices cache tag', async () => {
const { db } = await import('@/lib/db');
const { revalidateTag } = await import('next/cache');
vi.mocked(db.invoice.findUnique).mockResolvedValue(buildInvoice({ userId: 'user_1' }));
vi.mocked(db.invoice.update).mockResolvedValue(buildInvoice({ status: 'paid' }));
const result = await markInvoicePaid({ invoiceId: 'inv_test_001', paidAt: new Date('2026-03-01') });
expect(result).toEqual({ success: true });
expect(revalidateTag).toHaveBeenCalledWith('invoices');
});
it('returns field errors without calling the database when invoiceId is empty', async () => {
const { db } = await import('@/lib/db');
const result = await markInvoicePaid({ invoiceId: '', paidAt: new Date() });
expect(result.success).toBe(false);
expect(result.fieldErrors?.invoiceId).toBeDefined();
expect(db.invoice.update).not.toHaveBeenCalled();
});
});
Rules:
vi.clearAllMocks()inbeforeEachprevents state from leaking between tests.vi.mocked()provides typed access to mock functions. Never cast toany.- Import mocked modules inside the test body with
await import(...)aftervi.mock()hoisting. - Always test the Zod validation-failure path. Do not only cover the happy path.
For Prisma chains with relations, pagination, or $transaction, see references/mocking-patterns.md.
RSC testing strategy
Async Server Components cannot be rendered by React Testing Library. They execute on the server and return RSC payloads; jsdom has no mechanism for this. Use three complementary approaches.
Extract and unit-test the data layer. If an RSC calls db.invoice.findMany(), extract that into a standalone async function and test it in the node environment with a mocked Prisma client. The RSC becomes a thin rendering shell.
// src/lib/data/invoice-data.ts (extracted, testable)
export async function getInvoicesForUser(userId: string): Promise<Invoice[]> {
return db.invoice.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } });
}
// @vitest-environment node
it('returns an empty array when the user has no invoices', async () => {
vi.mocked(db.invoice.findMany).mockResolvedValue([]);
const result = await getInvoicesForUser('user_123');
expect(result).toEqual([]);
});
Test the Client Component leaves. The interactive parts of RSC trees are Client Components. Test those normally with RTL. The RSC serves as a server-rendered shell; the interactive leaves are fully covered by component tests.
Use E2E for full-page rendering. Playwright renders through the real Next.js server, the only environment where RSCs execute. Use Playwright for assertions that require the full RSC pipeline: Suspense boundaries, loading.tsx states, streaming.
// ❌ Async Server Components are server functions; render() does not work
render(<InvoicesPage />);
// ✅ Test the extracted data function in node environment
// ✅ Test the Client Component leaf separately
// ✅ Test the full page with Playwright
For mocking next/navigation, next/headers, cookies(), and Auth.js auth() in code called by RSCs or Server Actions, see references/rsc-patterns.md.
Hook testing
Test custom hooks with renderHook from React Testing Library. Wrap timer-dependent behavior with vi.useFakeTimers().
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useJobPoller } from './use-job-poller';
describe('useJobPoller', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('starts in pending state when a jobId is provided', () => {
const { result } = renderHook(() => useJobPoller('job_001'));
expect(result.current.status).toBe('pending');
});
it('transitions to completed when the polling interval resolves', async () => {
const fetchStatus = vi.fn().mockResolvedValue('completed');
const { result } = renderHook(() => useJobPoller('job_001', { fetchStatus }));
await act(() => vi.runAllTimersAsync());
expect(result.current.status).toBe('completed');
expect(fetchStatus).toHaveBeenCalledWith('job_001');
});
it('returns null status when no jobId is provided', () => {
const { result } = renderHook(() => useJobPoller(null));
expect(result.current.status).toBeNull();
});
});
Forbidden patterns
| Pattern | Reason |
|---|---|
| Snapshot tests for complex UI | Brittle; asserts structure, not behavior. Allowed only for simple deterministic pure-output functions. |
any in mock types |
Defeats TypeScript strictness in test files. Use vi.mocked() for typed mocks. |
| Real database calls | Slow, order-dependent, and fragile in CI. Mock Prisma at the module boundary. |
fireEvent for user interactions |
Skips the full browser event sequence. Use userEvent instead. |
// Arrange, // Act, // Assert comments |
Tests should read naturally. Blank lines separate the phases. |
| Inline domain object literals | Couples tests to schema shape. Use fixture factories. |
.forEach inside a single it block |
Hides individual failures in the report. Use it.each instead. |
Multiple unrelated assertions per it |
Makes failure diagnosis harder. One scenario per test. |
jest.* APIs |
This project uses Vitest. The equivalent APIs are vi.fn(), vi.mock(), vi.mocked(), vi.spyOn(). |
Validation checklist
After writing or modifying tests, verify:
-
npm testpasses with no failures -
// @vitest-environment nodepresent on all Server Action and utility test files - RTL queries use
getByRole,getByLabelText, orgetByTextas the first choice - All user interactions use
userEvent, notfireEvent - Multiple-input scenarios use
it.eachordescribe.each, not.forEach - Domain objects built with fixture factories; no raw inline literals
-
vi.clearAllMocks()called inbeforeEachwhen tests share mocked modules -
vi.mocked()used for typed mock access; noas jest.Mockcasts - Async Server Component logic extracted into standalone testable functions
- Zod validation-failure paths covered in Server Action tests
- New features have tests; bug fixes have regression tests