| name | test-ts |
| description | Write, review, and run TypeScript/React tests for this Next.js 16 App Router project. Use whenever writing or modifying *.test.ts or *.test.tsx files, adding test coverage to components, hooks, Server Actions, or utilities, setting up Vitest configuration, or asked about testing strategy. Covers Vitest (the project's test runner, not Jest), React Testing Library v16, accessibility-first queries, user-event for interactions, AAA structure, parameterized tests with describe.each and it.each, Prisma mock patterns, next/navigation and next/headers mocking, and the RSC boundary testing strategy. Do NOT use for Playwright end-to-end tests or performance benchmarks. |
| metadata | {"author":"Serghei Iakovlev","version":"1.0","category":"testing"} |
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
npm run test:watch
npm run test:coverage
npx vitest run src/components/invoice/
npx vitest run -t "renders the client"
npx vitest run path/to/component.test.tsx
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:
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.tsx maps to invoice-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 onMarkPaid = vi.fn();
const invoice = buildInvoice({ status: 'unpaid' });
render(<InvoiceCard invoice={invoice} onMarkPaid={onMarkPaid} />);
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:
describe names the component or function under test. Nest describe blocks to scope a scenario or method.
it reads as a complete sentence: it('disables the submit button while the mutation is pending').
- Each
it block 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; add data-testid only when no semantic query fits)
screen.getByRole('button', { name: /save invoice/i });
screen.getByLabelText('Invoice amount');
screen.getByRole('alert');
screen.getByRole('combobox', { name: 'Status' });
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.
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([
{ 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', () => { ... });
});
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.
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,
};
}
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.
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() in beforeEach prevents state from leaking between tests.
vi.mocked() provides typed access to mock functions. Never cast to any.
- Import mocked modules inside the test body with
await import(...) after vi.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.
export async function getInvoicesForUser(userId: string): Promise<Invoice[]> {
return db.invoice.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } });
}
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.
render(<InvoicesPage />);
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: