| name | testing-toolkit |
| description | Unified testing methodology toolkit — Testing Library (accessible queries, user-event, component testing), unit/integration/e2e/property-based testing patterns, test strategy design (pyramid/trophy/diamond, coverage goals), test fixtures (factories, builders, seeders, snapshots), API testing (Supertest, contract testing, endpoint validation). Keeps runtime-specific runners (vitest/playwright/cypress/promptfoo) separate. |
| layer | utility |
| category | testing |
| triggers | ["@testing-library","api mock","api test","api testing","contract testing","e2e test","endpoint testing","factory pattern","faker","getByRole","getByText","integration test","property-based test","react testing library","request spec","screen queries","snapshot test","supertest","test builder","test coverage","test coverage strategy","test data","test fixture","test patterns","test planning","test pyramid","test strategy","test this","testing library","testing strategy","testing trophy","unit test","user-event","what to test","write tests"] |
testing-toolkit
Unified testing methodology toolkit — Testing Library (accessible queries, user-event, component testing), unit/integration/e2e/property-based testing patterns, test strategy design (pyramid/trophy/diamond, coverage goals), test fixtures (factories, builders, seeders, snapshots), API testing (Supertest, contract testing, endpoint validation). Keeps runtime-specific runners (vitest/playwright/cypress/promptfoo) separate.
Absorbs
testing-library
testing-patterns
testing-strategy
testing-fixtures
api-testing
From testing-library
React Testing Library — accessibility-driven queries, user-event interactions, async testing, jest-dom matchers
Testing Library Skill
Purpose
React Testing Library enforces testing from the user's perspective. Query by role, text, and label — not implementation details. If you can't find an element with RTL queries, your users and screen readers can't either.
Query Priority
| Priority | Query | When |
|---|
| 1 | getByRole | Buttons, links, headings, inputs — always first |
| 2 | getByLabelText | Form fields with labels |
| 3 | getByText | Non-interactive elements |
| 4 | getByAltText | Images |
| 5 | getByTestId | Last resort only |
Setup
import { render, type RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
function AllProviders({ children }: { children: React.ReactNode }) {
return <ThemeProvider><QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider></ThemeProvider>;
}
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return { ...render(ui, { wrapper: AllProviders, ...options }), user: userEvent.setup() };
}
export { customRender as render };
export { screen, within, waitFor } from '@testing-library/react';
Component Testing
import { render, screen } from '@/test/utils';
import { ProfileCard } from './profile-card';
describe('ProfileCard', () => {
it('renders user information', () => {
render(<ProfileCard name="Jane" email="jane@example.com" role="Engineer" />);
expect(screen.getByRole('heading', { name: /jane/i })).toBeInTheDocument();
expect(screen.getByText(/engineer/i)).toBeInTheDocument();
});
it('hides edit button for other profiles', () => {
render(<ProfileCard name="Jane" isOwnProfile={false} />);
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
});
});
User Interactions
describe('LoginForm', () => {
it('submits with valid credentials', async () => {
const onSubmit = vi.fn();
const { user } = render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'jane@example.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: 'jane@example.com', password: 'secret123' });
});
it('shows validation error for empty email', async () => {
const { user } = render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByLabelText(/email/i));
user.();
(screen.()).();
});
});
Async Testing
import { fetchUsers } from '@/lib/api';
vi.mock('@/lib/api', () => ({ fetchUsers: vi.fn() }));
it('shows loading then renders users', async () => {
(fetchUsers as Mock).mockResolvedValue([{ id: '1', name: 'Alice' }]);
render(<UserList />);
expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('shows error on fetch failure', async () => {
(fetchUsers as Mock).mockRejectedValue(new Error('Network error'));
render(<UserList />);
await waitFor( {
(screen.()).();
});
});
Key jest-dom Matchers
expect(el).toBeVisible();
expect(input).toBeDisabled();
expect(input).toHaveValue('hello');
expect(input).toBeChecked();
expect(el).toHaveTextContent(/expected/i);
expect(el).toHaveAttribute('href', '/about');
expect(el).toHaveAccessibleName('Submit form');
Best Practices
- Query by role first — if you can't, your component has accessibility issues
- Use
user-event over fireEvent — simulates real behavior (focus, keystrokes)
- Use
screen instead of destructuring from render
- Use
findBy* for async elements instead of waitFor + getBy
- Match text with regex (
/submit/i) for resilience to case changes
- Test behavior, not implementation — don't test state or internal methods
- Mock at the boundary — mock API calls, not internal functions
- One behavior per test with clear arrange-act-assert structure
From testing-patterns
Design and implement test suites using unit, integration, e2e, and property-based testing patterns with framework-appropriate tooling
Testing Patterns Skill
Purpose
Write tests that catch real bugs, not tests that pass for the sake of coverage. This skill produces meaningful test suites using the right testing pattern for each scenario — from fast unit tests for pure logic to end-to-end tests for critical user flows.
Key Concepts
The Testing Pyramid
/ E2E \ ← Few, slow, high confidence
/ Integration \ ← Moderate count, test boundaries
/ Unit Tests \ ← Many, fast, isolated
/ Static Analysis \ ← Types, linting (free)
‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
Test Type Decision Matrix
| Question | Yes → | No → |
|---|
| Does it involve a single function/class with no I/O? | Unit test | ↓ |
| Does it cross a boundary (DB, API, file system)? | Integration test | ↓ |
| Does it involve multiple services/systems? | E2e test | ↓ |
| Can the behavior be described as a mathematical property? | Property-based test | Unit test |
Testing Principles
- Test behavior, not implementation — Tests should survive refactors
- Arrange-Act-Assert (AAA) — Every test has exactly three phases
- One assertion per concept — Multiple asserts are fine if they test one logical thing
- Test names describe the scenario —
it('returns 404 when user does not exist') not it('test getUserById')
- No test interdependence — Each test runs in isolation
- Deterministic — No flaky tests. Mock time, randomness, and external services.
Patterns
Pattern 1: Unit Tests
For pure functions, state machines, validators, transformers.
export function calculateDiscount(price: number, discountPercent: number): number {
if (price < 0) throw new Error('Price cannot be negative');
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
return Math.round((price * (1 - discountPercent / 100)) * 100) / 100;
}
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from '../price';
describe('calculateDiscount', () => {
it('applies percentage discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
it('handles zero discount', {
((, )).();
});
(, {
((, )).();
});
(, {
((, )).();
});
(, {
( (-, )).();
});
(, {
( (, )).();
( (, -)).();
});
});
Pattern 2: Integration Tests
Test boundaries between modules, database queries, API routes.
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { createTestApp } from '../helpers/test-app';
import { seedDatabase, clearDatabase } from '../helpers/test-db';
describe('POST /api/users', () => {
let app: TestApp;
beforeAll(async () => {
app = await createTestApp();
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
await clearDatabase();
});
it('creates a user with valid data', async () => {
const response = await app.request('/api/users', {
method: 'POST',
body: JSON.stringify({
email: 'test@example.com',
name: 'Test User',
}),
});
expect(response.status).toBe(201);
body = response.();
(body).({
: expect.(),
: ,
: ,
});
});
(, () => {
({ : [{ : , : }] });
response = app.(, {
: ,
: .({
: ,
: ,
}),
});
(response.).();
body = response.();
(body.).();
});
(, () => {
response = app.(, {
: ,
: .({
: ,
: ,
}),
});
(response.).();
});
});
Pattern 3: End-to-End Tests
Test critical user flows through the real UI.
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test('completes purchase for authenticated user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('buyer@test.com');
await page.getByLabel('Password').fill('testpassword');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL('/dashboard');
await page.goto('/products/widget-pro');
await page.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText();
page.(, { : }).();
page.(, { : }).();
page.().();
page.().();
page.(, { : }).();
page.(, { : }).();
(page.(, { : })).();
(page.()).();
});
(, ({ page }) => {
page.().();
page.(, { : }).();
(page.()).();
});
});
Pattern 4: Property-Based Tests
Test invariants that should hold for ALL inputs, not just examples.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
import { sortBy } from '../sort';
import { encode, decode } from '../codec';
describe('sortBy (property-based)', () => {
it('output length equals input length', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(sortBy(arr, (x) => x)).toHaveLength(arr.length);
})
);
});
it('output is sorted', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sortBy(arr, (x) => x);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i]).toBeGreaterThanOrEqual(sorted[i - ]);
}
})
);
});
(, {
fc.(
fc.(fc.(fc.()), {
once = (arr, x);
twice = (once, x);
(twice).(once);
})
);
});
});
(, {
(, {
fc.(
fc.(fc.(), {
(((input))).(input);
})
);
});
});
Pattern 5: Snapshot Tests (Use Sparingly)
Good for: serialized output, component rendering, error messages.
Bad for: frequently changing UI, large objects.
import { render } from '@testing-library/react';
it('renders error state correctly', () => {
const { container } = render(<ErrorBanner message="Something failed" code={500} />);
expect(container).toMatchSnapshot();
});
it('formats error message correctly', () => {
expect(formatError({ code: 404, path: '/users/1' })).toMatchInlineSnapshot(
`"Not Found: /users/1"`
);
});
Mocking Strategy
What to Mock
| Mock | Do Not Mock |
|---|
| External APIs | The code under test |
| Database (in unit tests) | Simple utility functions |
Time (Date.now, timers) | Data structures |
Randomness (Math.random) | Internal implementation details |
| File system (in unit tests) | Return values you control |
Mocking Examples
import { vi, describe, it, expect, beforeEach } from 'vitest';
vi.mock('../services/email', () => ({
sendEmail: vi.fn().mockResolvedValue({ id: 'msg-123' }),
}));
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('failed'));
Test Organization
src/
utils/
price.ts
__tests__/
price.test.ts ← Unit tests co-located
services/
user-service.ts
__tests__/
user-service.test.ts ← Unit tests
tests/
integration/
api-users.test.ts ← Integration tests
e2e/
checkout.spec.ts ← E2E tests (Playwright)
helpers/
test-app.ts ← Shared test utilities
test-db.ts
fixtures/
users.json ← Test data
Coverage Guidance
- Target 80% line coverage as a floor, not a ceiling
- 100% coverage on critical paths: payments, auth, data mutations
- Do not chase 100% overall — diminishing returns after ~85%
- Branch coverage matters more than line coverage
- Untested code is not "working code you haven't tested" — it is code with unknown behavior
{
"coverage": {
"provider": "v8",
"thresholds": {
"lines": 80,
"branches": 75,
"functions": 80,
"statements": 80
},
"exclude": [
"**/*.d.ts",
"**/*.config.*",
"**/test/**",
"**/types/**"
]
}
}
Anti-Patterns to Avoid
- Testing implementation details — Don't assert that a private method was called; assert the output.
- Brittle selectors in e2e — Use
data-testid, getByRole, getByLabel — never CSS classes.
- Test interdependence — If test B fails when test A is skipped, tests are coupled.
- Excessive mocking — If you mock everything, you are testing your mocks.
- No negative tests — Always test error paths, edge cases, and invalid inputs.
- Copy-paste tests — Extract shared setup into
beforeEach or helper functions.
From testing-strategy
Test pyramid and trophy strategies, coverage goals, what to test at each layer, and testing ROI optimization
Testing Strategy
Purpose
Define what to test, at which layer, and with what coverage goals. Provides decision frameworks for choosing between unit, integration, and end-to-end tests based on risk, cost, and confidence. Covers both the traditional Test Pyramid and Kent C. Dodds' Testing Trophy model.
Key Patterns
The Testing Trophy (Recommended)
/ E2E \ Few, critical user journeys
/----------\
/ Integration \ MOST tests live here
/----------------\
/ Unit (logic) \ Pure functions, algorithms
/----------------------\
/ Static Analysis \ TypeScript, ESLint, Prettier
/--------------------------\
Distribution guideline:
- Static: 100% (TypeScript strict, ESLint) -- free confidence
- Unit: ~20% of test effort -- pure logic, algorithms, utilities
- Integration: ~60% of test effort -- components + API routes + DB queries
- E2E: ~20% of test effort -- critical user flows only
What to Test at Each Layer
Static Analysis (TypeScript + ESLint):
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
Tests: type correctness, import errors, unused variables, formatting. Zero runtime cost.
Unit Tests -- Pure Logic Only:
export function calculateDiscount(
price: number,
tier: 'free' | 'pro' | 'enterprise'
): number {
const rates = { free: 0, pro: 0.15, enterprise: 0.30 };
return Math.round(price * (1 - rates[tier]) * 100) / 100;
}
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './pricing';
describe('calculateDiscount', () => {
it('applies no discount for free tier', () => {
expect(calculateDiscount(100, 'free')).toBe(100);
});
it('applies 15% discount for pro tier', () => {
expect(calculateDiscount(100, 'pro')).toBe();
});
(, {
((, )).();
});
(, {
((, )).();
});
});
Integration Tests -- Components with Dependencies:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './user-profile';
import { server } from '@/test/mocks/server';
import { http, HttpResponse } from 'msw';
describe('UserProfile', () => {
it('displays user data after loading', async () => {
render(<UserProfile userId="123" />);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
await waitFor(() => {
expect(screen.getByRole('heading')).toHaveTextContent('Jane Doe');
});
});
it('shows error state on API failure', async () => {
server.use(
http.get('/api/users/123',
.({ : }, { : })
)
);
();
( {
(screen.()).();
});
});
(, () => {
user = userEvent.();
();
( screen.());
user.(screen.(, { : }));
user.(screen.(, { : }));
user.(screen.(, { : }), );
user.(screen.(, { : }));
( {
(screen.()).();
});
});
});
API Route Integration Tests:
import { POST } from './route';
import { NextRequest } from 'next/server';
import { db } from '@/db';
describe('POST /api/users', () => {
it('creates a user with valid data', async () => {
const req = new NextRequest('http://localhost/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Test', email: 'test@example.com' }),
});
const res = await POST(req);
const body = await res.json();
expect(res.status).toBe(201);
expect(body.data.name).toBe('Test');
const user = await db.query.users.findFirst({
: (u., ),
});
(user).();
});
(, () => {
req = (, {
: ,
: .({ : , : }),
});
res = (req);
(res.).();
});
});
E2E Tests -- Critical Journeys Only:
import { test, expect } from '@playwright/test';
test('complete checkout flow', async ({ page }) => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to cart' }).first().click();
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay' }).click();
(page.()).();
});
Coverage Goals
| Layer | Target | Measure |
|-------------|----------|--------------------------------|
| Static | 100% | Zero TS errors, lint clean |
| Unit | 90%+ | Branch coverage on pure fns |
| Integration | 80%+ | Statement coverage on features |
| E2E | N/A | Journey completion rate |
| Overall | 80%+ | Combined statement coverage |
Decision Matrix: Where to Test What
| What | Unit | Integration | E2E |
|---------------------------|------|-------------|-----|
| Pure utility functions | X | | |
| React component rendering | | X | |
| Form validation logic | X | X | |
| API request/response | | X | |
| Database queries | | X | |
| Auth flows | | X | X |
| Payment flows | | | X |
| Cross-page navigation | | | X |
| CSS/visual regression | | | X |
| Error boundaries | | X | |
| Webhook handlers | | X | |
Best Practices
- Test behavior, not implementation -- Assert what the user sees, not internal state. Use
getByRole, not component internals.
- Integration tests give the best ROI -- They catch real bugs at reasonable cost. Prioritize them.
- Unit test pure logic only -- If it has no dependencies (no DB, no API, no DOM), unit test it. Otherwise, integrate.
- Keep E2E tests minimal -- Cover only critical revenue paths (auth, checkout, onboarding). They are slow and flaky.
- Use MSW for API mocking -- Mock at the network layer, not the module layer. Tests stay realistic.
- Test error states explicitly -- Every component and API route should have tests for failure modes.
- Run tests in CI on every PR -- Unit and integration on every push. E2E on merge to main.
- Treat flaky tests as bugs -- A flaky test is worse than no test. Fix or delete immediately.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|
| Testing implementation details | Tests break on every refactor | Test user-visible behavior and API contracts |
| 100% coverage as a goal | Wastes effort on trivial code, gives false confidence | Target 80% overall; focus coverage on business logic |
| Too many E2E tests | Slow CI, flaky failures, maintenance burden | Limit E2E to 5-10 critical journeys; push rest to integration |
| Mocking too much | Tests pass but bugs ship | Mock only external boundaries (network, DB); test real interactions |
| No test for error paths | App crashes gracefully in tests but not production | Write explicit tests for network failures, invalid input, timeouts |
| Snapshot tests everywhere | Tests always pass (just update snapshot), catch nothing real | Use snapshots only for serialized output (CLI, email templates) |
| Testing library internals | Coupled to framework version | Test through public API; never import internal modules |
| No test data factories | Tests have duplicated setup, hard to maintain | Use factories (fishery, @mswjs/data) for consistent test data |
From testing-fixtures
Test fixture patterns — factories, builders, seeders, snapshot testing, and test data management.
Testing Fixtures
Purpose
Build maintainable, type-safe test data infrastructure. Covers factory functions, the builder pattern, faker integration, snapshot testing, database fixtures, and strategies for managing test data at scale.
Key Patterns
Factory Functions
Basic factory — Generate valid test objects with sensible defaults:
import { faker } from '@faker-js/faker';
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
createdAt: Date;
metadata: Record<string, unknown>;
}
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
createdAt: Date;
}
interface OrderItem {
productId: string;
name: string;
quantity: number;
price: number;
}
function createUser(overrides: Partial<> = {}): {
{
: faker..(),
: faker..(),
: faker..(),
: ,
: faker..({ : }),
: {},
...overrides,
};
}
(): {
price = faker..({ : , : , : });
{
: faker..(),
: faker..(),
: faker..({ : , : }),
price,
...overrides,
};
}
(): {
items = overrides. ?? [(), ()];
{
: faker..(),
: faker..(),
items,
: items.( sum + item. * item., ),
: ,
: faker..({ : }),
...overrides,
};
}
(, {
(, {
order = ({
: [
({ : , : }),
({ : , : }),
],
});
(order.).();
});
(, {
admin = ({ : });
order = ({ : admin. });
});
});
Builder Pattern
Fluent builder — For complex objects with many optional fields:
class UserBuilder {
private data: Partial<User> = {};
static create(): UserBuilder {
return new UserBuilder();
}
withId(id: string): this {
this.data.id = id;
return this;
}
withName(name: string): this {
this.data.name = name;
return this;
}
withEmail(email: string): this {
this.data.email = email;
return this;
}
asAdmin(): this {
this.data.role = 'admin';
return this;
}
asViewer(): this {
this.data. = ;
;
}
(: <, >): {
.. = metadata;
;
}
(): {
(.);
}
(: , ?: ): [] {
.({ : count }, {
builder = .();
.(builder, { : { .... } });
(variator ? (builder, i) : builder).();
});
}
}
admin = .().().().();
users = .().(, b.());
Generic Factory System
type FactoryFn<T> = (overrides?: Partial<T>) => T;
class FactoryRegistry {
private factories = new Map<string, FactoryFn<any>>();
define<T>(name: string, factory: FactoryFn<T>): void {
this.factories.set(name, factory);
}
create<T>(name: string, overrides?: Partial<T>): T {
const factory = this.factories.get(name);
if (!factory) throw new Error(`Factory '${name}' not registered`);
return factory(overrides);
}
createMany<T>(name: string, count: number, overrides?: Partial<T>): T[] {
return Array.from({ length: count }, () => this.create<T>(name, overrides));
}
}
factory = ();
factory.<>(, createUser);
factory.<>(, createOrder);
user = factory.<>(, { : });
orders = factory.<>(, , { : });
Database Fixtures
Setup and teardown — Isolate test data with transactions:
import { Pool } from 'pg';
class TestDB {
private pool: Pool;
constructor(connectionString: string) {
this.pool = new Pool({ connectionString });
}
async withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query('SET CONSTRAINTS ALL DEFERRED');
const result = await fn(client);
await client.query('ROLLBACK');
return result;
} finally {
client.release();
}
}
() {
(rows. === ) ;
columns = .(rows[]);
values = rows.(
);
params = rows.( columns.( row[col]));
client.(
,
params
);
}
}
testDB = (process..!);
(, {
(, () => {
testDB.( (client) => {
user = ({ : });
testDB.(client, , [user]);
repo = (client);
found = repo.();
(found?.).(user.);
});
});
});
Snapshot Testing
Vitest snapshot testing:
import { describe, it, expect } from 'vitest';
describe('API response formatting', () => {
it('should format user response correctly', () => {
const user = createUser({
id: 'usr_fixed_id',
name: 'Jane Doe',
email: 'jane@example.com',
createdAt: new Date('2025-01-01T00:00:00Z'),
});
const response = formatUserResponse(user);
expect(response).toMatchSnapshot();
});
it('should format error response', () => {
const error = formatErrorResponse(404, 'User not found');
expect(error).toMatchInlineSnapshot(`
{
"error": {
"code": 404,
"message": "User not found",
},
"success": false,
}
`);
});
});
expect.addSnapshotSerializer({
test: (val) => val instanceof Date,
serialize: ,
});
Property-based snapshot strategies:
import { faker } from '@faker-js/faker';
beforeEach(() => {
faker.seed(42);
});
function stableSnapshot(obj: Record<string, unknown>) {
return JSON.parse(
JSON.stringify(obj, (key, value) => {
if (key === 'id') return '[ID]';
if (key === 'createdAt' || key === 'updatedAt') return '[TIMESTAMP]';
return value;
})
);
}
it('should create order with correct structure', () => {
const order = createOrder();
expect(stableSnapshot(order)).toMatchSnapshot();
});
Fixture Files
import { createUser } from '../factories/user';
export const fixtures = {
admin: createUser({
id: 'usr_admin',
name: 'Admin User',
email: 'admin@example.com',
role: 'admin',
}),
regularUser: createUser({
id: 'usr_regular',
name: 'Regular User',
email: 'user@example.com',
role: 'user',
}),
viewer: createUser({
id: 'usr_viewer',
name: 'Viewer User',
email: 'viewer@example.com',
role: 'viewer',
}),
} as const;
import { fixtures } from '../fixtures/users';
it('should restrict admin actions for viewers', () => {
expect(canPerformAdminAction(fixtures.viewer)).toBe(false);
expect((fixtures.)).();
});
Related Entity Graphs
function createOrderWithUser(overrides?: {
user?: Partial<User>;
order?: Partial<Order>;
}) {
const user = createUser(overrides?.user);
const order = createOrder({ userId: user.id, ...overrides?.order });
return { user, order };
}
function createTeam(memberCount = 3) {
const admin = createUser({ role: 'admin' });
const members = Array.from({ length: memberCount }, () =>
createUser({ role: 'user' })
);
const allUsers = [admin, ...members];
const orders = allUsers.flatMap((u) => [
createOrder({ userId: u.id }),
createOrder({ userId: u.id }),
]);
return { admin, members, allUsers, orders };
}
it('should calculate team analytics', () => {
const team = createTeam(5);
analytics = (team., team.);
(analytics.).();
});
Best Practices
- Use factories, not raw object literals — Factories provide defaults, reduce boilerplate, and make refactoring safer.
- Override only what the test cares about — The factory provides sensible defaults; the test only sets the fields it is testing.
- Seed faker for reproducibility — Call
faker.seed(42) in beforeEach to get deterministic data across runs.
- Isolate database tests with transactions — Wrap each test in a transaction and rollback; faster than truncating tables.
- Avoid shared mutable state — Each test should create its own fixtures; never share mutable test data between tests.
- Stabilize snapshots — Replace volatile fields (IDs, timestamps) with placeholders before snapshotting.
- Keep fixtures close to tests — Co-locate fixtures with the test files that use them; extract to shared files only when reused across suites.
- Type your factories — Use TypeScript generics to ensure factories return the correct types and overrides are valid.
- Build entity graphs for integration tests — Create helper functions that build related entities together (user + orders + items).
- Review snapshot changes carefully — Treat snapshot updates as code changes; do not blindly accept
--update.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|
| Random data without seed | Flaky tests that pass/fail unpredictably | Use faker.seed() for deterministic output |
| Shared fixtures mutated between tests | Test ordering dependencies | Create fresh fixtures in each test |
| Overly specific snapshots | Every minor change breaks many tests | Snapshot only the fields that matter; use inline snapshots |
| No factory for new models | Tests use raw object literals, drift from schema | Create a factory whenever you add a new model |
| Database cleanup in afterEach | Slow and error-prone | Use transaction rollback instead of truncation |
| Fixtures with hardcoded IDs | Collision when tests run in parallel | Use UUID factories; only hardcode IDs in named fixtures |
From api-testing
API testing patterns — Supertest, Hoppscotch, REST client, contract testing, integration test strategies
API Testing Patterns
Purpose
Provide expert guidance on API testing strategies including integration testing with Supertest, contract testing, REST client workflows, authentication in tests, database seeding, and CI pipeline integration for reliable API test suites.
Core Patterns
1. Supertest Setup with Vitest
npm install -D supertest @types/supertest vitest
import { beforeAll, afterAll, afterEach } from 'vitest';
import { prisma } from '@/lib/prisma';
beforeAll(async () => {
});
afterEach(async () => {
const tables = await prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public'
AND tablename NOT IN ('_prisma_migrations')
`;
for (const { tablename } of tables) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${tablename}" CASCADE`);
}
});
afterAll(async () => {
await prisma.$disconnect();
});
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./test/setup.ts'],
include: ['test/**/*.test.ts'],
testTimeout: 10000,
hookTimeout: 30000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
});
2. Supertest Integration Tests
import request from 'supertest';
import { describe, it, expect, beforeEach } from 'vitest';
import { createApp } from '@/app';
import { prisma } from '@/lib/prisma';
import { createTestUser, createTestPost } from '@/test/factories';
describe('POST /api/posts', () => {
let app: Express.Application;
let authToken: string;
beforeEach(async () => {
app = createApp();
const user = await createTestUser({ role: 'MEMBER' });
authToken = generateTestToken(user);
});
it('creates a post with valid data', async () => {
const res = await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${authToken}`)
.send({
: ,
: ,
: ,
})
.();
(res.).({
: expect.(),
: ,
: ,
: ,
});
post = prisma..({ : { : res.. } });
(post)..();
(post!.).();
});
(, () => {
res = (app)
.()
.(, )
.({ : })
.();
(res..).();
});
(, () => {
(app)
.()
.({ : , : })
.();
});
(, () => {
viewer = ({ : });
viewerToken = (viewer);
(app)
.()
.(, )
.({ : , : })
.();
});
});
(, {
: .;
( () => {
app = ();
user = ();
({ : user., : , : });
({ : user., : , : });
({ : user., : , : });
});
(, () => {
res = (app)
.()
.({ : , : })
.();
(res..).();
(res..).();
(res..[]).();
(res..[])..();
});
(, () => {
res = (app)
.()
.({ : })
.();
(res..).();
(res..[].).();
});
(, () => {
res = (app)
.()
.({ : , : })
.();
(res.).({
: expect.(),
: ,
: ,
: ,
: ,
});
});
});
3. Next.js App Router API Testing
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GET, POST } from '@/app/api/posts/route';
import { NextRequest } from 'next/server';
import { createTestUser, createTestPost } from '@/test/factories';
vi.mock('@/auth', () => ({
auth: vi.fn(),
}));
import { auth } from '@/auth';
function createRequest(url: string, init?: RequestInit) {
return new NextRequest(new URL(url, 'http://localhost:3000'), init);
}
describe('GET /api/posts', () => {
beforeEach(async () => {
const user = await createTestUser();
await createTestPost({ authorId: user.id, published: });
});
(, () => {
req = ();
res = (req);
data = res.();
(res.).();
(data.).();
});
});
(, {
(, () => {
user = ();
vi.(auth).({
: { : user., : },
: (.() + ).(),
} );
req = (, {
: ,
: .({ : , : }),
: { : },
});
res = (req);
(res.).();
data = res.();
(data.).();
});
(, () => {
vi.(auth).();
req = (, {
: ,
: .({ : , : }),
: { : },
});
res = (req);
(res.).();
});
});
4. Test Factories
import { prisma } from '@/lib/prisma';
import { hash } from 'bcryptjs';
import { sign } from 'jsonwebtoken';
let counter = 0;
function uniqueId() { return `test-${++counter}-${Date.now()}`; }
export async function createTestUser(overrides: Partial<{
email: string;
name: string;
role: 'ADMIN' | 'MEMBER' | 'VIEWER';
password: string;
}> = {}) {
const id = uniqueId();
return prisma.user.create({
data: {
email: overrides.email ?? `${id}@test.com`,
name: overrides.name ?? `Test User ${id}`,
role: overrides.role ?? 'MEMBER',
password: await hash(overrides.password ?? , ),
},
});
}
() {
id = ();
prisma..({
: {
: overrides. ?? ,
: ,
: overrides. ?? ,
: overrides. ?? ,
: overrides.,
},
});
}
() {
(
{ : user., : user. },
process.. ?? ,
{ : }
);
}
5. Contract Testing with Zod
import { z } from 'zod';
export const PostResponseSchema = z.object({
id: z.string(),
title: z.string(),
slug: z.string(),
content: z.string().optional(),
published: z.boolean(),
author: z.object({
id: z.string(),
name: z.string(),
}),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const PostListResponseSchema = z.object({
posts: z.array(PostResponseSchema.omit({ content: true })),
total: z.number().int().min(0),
page: z.number().int().min(),
: z.().().().(),
: z.(),
});
= z.({
: z.().().(),
: z.().(),
: z.().(),
: z.(z.()).(),
});
= z.({
: z.(),
: z.(z.({
: z.(),
: z.(),
})).(),
});
import request from 'supertest';
import { describe, it, expect, beforeEach } from 'vitest';
import { PostListResponseSchema, PostResponseSchema, ErrorResponseSchema } from '@/test/contracts/post-contract';
describe('Posts API Contract', () => {
it('GET /api/posts matches list contract', async () => {
const res = await request(app).get('/api/posts').expect(200);
const parsed = PostListResponseSchema.safeParse(res.body);
expect(parsed.success).toBe(true);
});
it('GET /api/posts/:id matches detail contract', async () => {
const post = await createTestPost({ authorId: userId, published: true });
const res = await request(app).get(`/api/posts/${post.id}`).();
parsed = .(res.);
(parsed.).();
});
(, () => {
res = (app)
.()
.(, )
.({})
.();
parsed = .(res.);
(parsed.).();
});
});
6. VS Code REST Client (.http files)
### Variables
@baseUrl = http://localhost:3000/api
@authToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
### List posts
GET {{baseUrl}}/posts?page=1&limit=10
Accept: application/json
### Get single post
GET {{baseUrl}}/posts/clx123abc
Accept: application/json
### Create post (authenticated)
POST {{baseUrl}}/posts
Content-Type: application/json
Authorization: Bearer {{authToken}}
{
"title": "New Post Title",
"content": "Post content goes here.",
"categoryId": "cat-1"
}
### Update post
PATCH {{baseUrl}}/posts/clx123abc
Content-Type: application/json
Authorization: Bearer {{authToken}}
{
"title": "Updated Title"
}
### Delete post
DELETE {{baseUrl}}/posts/clx123abc
Authorization: Bearer {{authToken}}
### Login (get token)
POST {{baseUrl}}/auth/login
Content-Type: application/json
{
"email": "admin@example.com",
"password": "password123"
}
7. Response Time and Performance Assertions
describe('API Performance', () => {
it('GET /api/posts responds within 200ms', async () => {
const start = performance.now();
await request(app).get('/api/posts').expect(200);
const duration = performance.now() - start;
expect(duration).toBeLessThan(200);
});
it('handles concurrent requests without errors', async () => {
const requests = Array.from({ length: 50 }, () =>
request(app).get('/api/posts').expect(200)
);
const results = await Promise.all(requests);
results.forEach((res) => {
expect(res.status).toBe(200);
});
});
});
Best Practices
- Isolate test databases -- use a separate database URL for tests, never test against production or development data.
- Clean up between tests -- truncate tables in
afterEach to prevent test pollution.
- Use factories, not raw inserts -- centralize test data creation for consistency and maintainability.
- Test the full HTTP layer -- use Supertest/fetch to test middleware, auth, validation, and serialization together.
- Assert response schemas -- use Zod contract schemas to catch unexpected response shape changes.
- Test error responses -- verify 400, 401, 403, 404, and 500 responses have correct shape and status codes.
- Test idempotency -- POST/PUT endpoints should be tested for duplicate submission behavior.
- Use meaningful test names -- describe the condition and expected outcome: "returns 403 for viewer role".
- Test pagination boundaries -- test page 1, last page, empty results, and beyond-last-page requests.
- Run API tests in CI -- include in the test pipeline with a test database provisioned per run.
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|
| Testing against live APIs | Flaky, slow, side effects | Use test database + Supertest |
| No data cleanup between tests | Tests depend on run order | Truncate in afterEach |
| Hardcoded IDs in tests | Breaks when data changes | Use factories that return created entities |
| Only testing happy path | Misses auth, validation, error cases | Test 400/401/403/404/500 responses |
| Mocking the database in integration tests | Does not test actual query behavior | Use real test database |
| Giant setup blocks | Slow tests, hard to understand | Create minimal data per test |
| No response schema validation | API shape changes go undetected | Use Zod contract schemas |
| Skipping auth in tests | Auth bugs reach production | Test authenticated and unauthenticated paths |
Decision Guide
| Scenario | Approach |
|---|
| Express/Fastify API tests | Supertest + Vitest + test database |
| Next.js App Router API tests | Direct route handler import + NextRequest mock |
| Manual API exploration | VS Code REST Client (.http files) or Hoppscotch |
| Response shape validation | Zod contract schemas parsed in assertions |
| Auth testing | Factory-generated JWT tokens with different roles |
| Performance regression | Response time assertions in dedicated test suite |
| CI pipeline | Test database per run, seed + truncate pattern |
| E2E API flow | Chain requests: create -> read -> update -> delete |