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.