Iโve worked on codebases with 95% test coverage that were impossible to refactor, and codebases with 30% coverage where every test caught real bugs. The difference wasnโt how many tests we wrote โ it was WHAT we tested and HOW.
After spending five years building and maintaining test suites for production applications, hereโs my comprehensive guide to testing in 2025 โ what to test, how to test it, and most importantly, what NOT to test.
The Testing Trophy (Not Pyramid)
Forget the traditional testing pyramid. In 2025, I follow Kent C. Doddsโ Testing Trophy:
โญโโโโโโฎ
โ E2E โ Few (critical paths)
โญโโโโดโโโโโโดโโโโฎ
โ Integration โ Most tests here
โญโโโโดโโโโโโโโโโโโโโดโโโโฎ
โ Unit (pure logic) โ Some
โฐโโโโโโโโโโโโโโโโโโโโโโฏ
Static Analysis Always (TypeScript, ESLint)
| Test Type | % of Test Suite | Speed | Confidence | Maintenance |
|---|---|---|---|---|
| Static Analysis | Continuous | Instant | Medium | Very Low |
| Unit Tests | 20-30% | Very Fast | Low-Medium | Low |
| Integration Tests | 50-60% | Fast | High | Medium |
| E2E Tests | 10-20% | Slow | Very High | High |
Pro Tip: Integration tests give you the best bang for your buck. They test real behavior (multiple units working together) without the brittleness of E2E tests. If youโre time-constrained, prioritize integration tests over unit tests.
Setting Up Your Test Stack (2025)
Hereโs the stack I use for every project:
# Install
npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D @playwright/test
npm install -D msw # API mocking
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules', 'tests', '**/*.d.ts', '**/*.config.*'],
},
},
});
// tests/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Unit Tests: Test Pure Logic
Unit tests are best for pure functions โ things with clear inputs and outputs, no side effects:
// utils/pricing.ts
export function calculateDiscount(
price: number,
quantity: number,
membershipTier: 'free' | 'pro' | 'enterprise'
): number {
let discount = 0;
if (quantity >= 10) discount += 0.1;
if (quantity >= 50) discount += 0.05;
switch (membershipTier) {
case 'pro': discount += 0.15; break;
case 'enterprise': discount += 0.25; break;
}
return Math.round(price * (1 - Math.min(discount, 0.4)) * 100) / 100;
}
// utils/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './pricing';
describe('calculateDiscount', () => {
it('applies no discount for free tier with low quantity', () => {
expect(calculateDiscount(100, 5, 'free')).toBe(100);
});
it('applies quantity discount at 10+ items', () => {
expect(calculateDiscount(100, 10, 'free')).toBe(90);
});
it('stacks quantity and membership discounts', () => {
expect(calculateDiscount(100, 10, 'pro')).toBe(75); // 10% + 15%
});
it('caps total discount at 40%', () => {
expect(calculateDiscount(100, 50, 'enterprise')).toBe(60); // would be 40%, capped
});
it('handles decimal prices correctly', () => {
expect(calculateDiscount(29.99, 10, 'pro')).toBe(22.49);
});
});
When to Write Unit Tests
โ Unit test these:
- Pure utility functions
- Data transformations
- Validation logic
- State machines
- Complex calculations
- Custom hooks (with renderHook)
โ Donโt unit test these:
- React components (use integration tests)
- API calls (use integration tests with MSW)
- Implementation details (private methods, internal state)
Integration Tests: The Sweet Spot
Integration tests verify that multiple pieces work together correctly. For React apps, this means testing components with their real children, hooks, and mocked external dependencies:
// features/checkout/CheckoutForm.tsx
export function CheckoutForm({ productId }: { productId: string }) {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('loading');
const formData = new FormData(e.currentTarget);
try {
await createOrder({
productId,
email: formData.get('email') as string,
quantity: Number(formData.get('quantity')),
});
setStatus('success');
} catch {
setStatus('error');
}
}
if (status === 'success') {
return <p role="alert">Order placed successfully!</p>;
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required placeholder="Email" />
<input name="quantity" type="number" min="1" defaultValue="1" />
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Processing...' : 'Place Order'}
</button>
{status === 'error' && <p role="alert">Something went wrong</p>}
</form>
);
}
// features/checkout/CheckoutForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { CheckoutForm } from './CheckoutForm';
const server = setupServer(
http.post('/api/orders', () => {
return HttpResponse.json({ id: 'ord_123', status: 'created' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('CheckoutForm', () => {
it('submits order and shows success message', async () => {
const user = userEvent.setup();
render(<CheckoutForm productId="prod_abc" />);
await user.type(screen.getByPlaceholderText('Email'), '[email protected]');
await user.clear(screen.getByRole('spinbutton'));
await user.type(screen.getByRole('spinbutton'), '3');
await user.click(screen.getByRole('button', { name: /place order/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Order placed successfully');
});
});
it('shows error state when API fails', async () => {
server.use(
http.post('/api/orders', () => {
return HttpResponse.json({ error: 'Out of stock' }, { status: 400 });
})
);
const user = userEvent.setup();
render(<CheckoutForm productId="prod_abc" />);
await user.type(screen.getByPlaceholderText('Email'), '[email protected]');
await user.click(screen.getByRole('button', { name: /place order/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong');
});
});
it('disables button during submission', async () => {
const user = userEvent.setup();
render(<CheckoutForm productId="prod_abc" />);
await user.type(screen.getByPlaceholderText('Email'), '[email protected]');
await user.click(screen.getByRole('button', { name: /place order/i }));
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveTextContent('Processing...');
});
});
Pro Tip: Use MSW (Mock Service Worker) instead of mocking fetch/axios directly. MSW intercepts at the network level, so your tests exercise the full request/response cycle including error handling, headers, and serialization. Itโs the closest to real behavior without hitting actual APIs.
E2E Tests: Critical User Journeys
E2E tests run against your full application. Reserve them for critical business flows:
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test('complete purchase as logged-in user', async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
// Add to cart
await page.goto('/products/premium-plan');
await page.click('text=Add to Cart');
await expect(page.locator('.cart-count')).toHaveText('1');
// Checkout
await page.click('text=Checkout');
await page.fill('[name="card"]', '4242424242424242');
await page.fill('[name="expiry"]', '12/26');
await page.fill('[name="cvc"]', '123');
await page.click('text=Pay Now');
// Confirmation
await expect(page.locator('h1')).toHaveText('Order Confirmed');
await expect(page.locator('.order-id')).toBeVisible();
});
test('shows validation errors for invalid payment', async ({ page }) => {
await page.goto('/checkout');
await page.click('text=Pay Now');
await expect(page.locator('.error')).toContainText('Card number is required');
});
});
Playwright Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
API Testing: Backend Integration
For backend APIs, integration tests should hit real endpoints with a test database:
// tests/api/users.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { app } from '../../src/app';
import { db } from '../../src/database';
describe('POST /api/users', () => {
beforeAll(async () => {
await db.migrate.latest();
});
afterAll(async () => {
await db.destroy();
});
it('creates a user with valid data', async () => {
const response = await app.request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Test User',
email: '[email protected]',
}),
});
expect(response.status).toBe(201);
const user = await response.json();
expect(user).toMatchObject({
name: 'Test User',
email: '[email protected]',
id: expect.any(String),
});
});
it('returns 400 for invalid email', async () => {
const response = await app.request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test', email: 'not-an-email' }),
});
expect(response.status).toBe(400);
const error = await response.json();
expect(error.error.details[0].field).toBe('email');
});
it('returns 409 for duplicate email', async () => {
// Create first user
await app.request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'First', email: '[email protected]' }),
});
// Try duplicate
const response = await app.request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Second', email: '[email protected]' }),
});
expect(response.status).toBe(409);
});
});
Test Speed Optimization
| Technique | Impact | Effort |
|---|---|---|
| Run Vitest in watch mode | Instant feedback | None |
Use --shard for CI parallelism |
3-4x faster CI | Low |
| Mock heavy dependencies (DB, network) | 5-10x faster | Medium |
| Use test containers for integration | Real DB, isolated | Medium |
| Profile and fix slow tests | Variable | Medium |
# Shard tests across CI workers
vitest run --shard=1/4 # Worker 1 of 4
vitest run --shard=2/4 # Worker 2 of 4
# Run only related tests (git-based)
vitest --changed # Tests related to uncommitted changes
For running tests in GitHub Actions, use matrix builds to parallelize shards.
Common Mistakes
1. Testing Implementation Details
// โ Bad: Tests internal state
it('sets isLoading to true', () => {
const { result } = renderHook(() => useUsers());
act(() => result.current.fetchUsers());
expect(result.current.isLoading).toBe(true); // Implementation detail!
});
// โ
Good: Tests behavior the user sees
it('shows loading spinner while fetching', async () => {
render(<UserList />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
2. Over-Mocking
// โ Bad: Everything is mocked, test proves nothing
vi.mock('./userService');
vi.mock('./database');
vi.mock('./validator');
vi.mock('./logger');
// โ
Good: Mock only external boundaries
vi.mock('./externalApi'); // Mock the HTTP boundary
// Let internal modules interact naturally
3. Brittle Selectors
// โ Brittle: Breaks when CSS classes change
screen.getByClassName('btn-primary-lg');
// โ Brittle: Breaks when DOM structure changes
container.querySelector('div > div > button:first-child');
// โ
Resilient: Uses semantic queries
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email address');
screen.getByTestId('checkout-form'); // Last resort
4. Not Testing Error States
I see this constantly โ tests only cover the happy path. Real applications fail, and users need to see useful error messages:
it('shows error when payment fails', async () => {
server.use(
http.post('/api/payment', () => HttpResponse.json(
{ error: 'Card declined' }, { status: 402 }
))
);
// ... fill form ...
await expect(screen.findByText(/card declined/i)).resolves.toBeInTheDocument();
expect(screen.getByRole('button')).not.toBeDisabled(); // Can retry
});
5. Slow E2E Tests Blocking Deployment
# Run E2E only on main branch or when PR is labeled "needs-e2e"
jobs:
e2e:
if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'needs-e2e')
What to Test: A Decision Framework
| Question | If Yes โ | If No โ |
|---|---|---|
| Is it pure logic? | Unit test | Integration test |
| Does it involve UI? | Integration test (Testing Library) | Unit test or API test |
| Is it a critical user journey? | E2E test | Integration test |
| Does it cross system boundaries? | Integration test with mocked boundary | Unit test |
| Could it break silently? | Definitely test it | Maybe skip |
| Is it just wiring/glue code? | Skip (TypeScript catches this) | Skip |
FAQ
How much test coverage should I aim for?
Donโt aim for a coverage number โ aim for confidence. I target 70-80% line coverage as a natural result of testing behavior that matters. 100% coverage is usually a waste (youโll test getters and setters). Low coverage (<50%) usually means critical paths are untested. Use coverage reports to find UNTESTED critical code, not as a target metric.
Should I use Jest or Vitest in 2025?
Vitest for new projects, hands down. Itโs faster (native ESM, Vite-powered), has better TypeScript support, and uses the same config as your Vite dev server. Jest is fine for existing projects โ donโt migrate just for the sake of it. But for anything new, Vitest + Playwright is the modern standard. Even Bunโs test runner is worth considering for Bun-based projects.
When should I write tests first (TDD)?
TDD works best for: pure functions with clear requirements, bug fixes (write a failing test that reproduces the bug, then fix it), and algorithmic code. TDD works poorly for: UI development (you donโt know the final design yet), prototyping, and exploring unfamiliar APIs. I use TDD about 30% of the time โ specifically when I know exactly what the function should do.
How do I test database-dependent code?
Three approaches: (1) Use testcontainers to spin up a real PostgreSQL in Docker for each test run, (2) Use an in-memory SQLite database with the same schema, (3) Mock the database layer entirely. I prefer option 1 for integration tests (real database behavior) and option 3 for unit tests (fast, isolated). PostgreSQL-specific testing tips are in our dedicated guide.
How do I handle flaky tests?
First, identify them: track tests that fail intermittently. Common causes: timing issues (use waitFor properly), shared state between tests, race conditions in async code, and network-dependent tests without proper mocking. Fix the root cause โ donโt just add retries. If a test is inherently flaky (E2E with real third-party services), quarantine it in a separate pipeline.
