A

Testing Overview

testingunit-testingintegration-testinge2ebest-practices

Testing Overview

Testing is how you prove your code works—and more importantly, how you prove it keeps working as your codebase evolves. A well-tested application gives you confidence to refactor, ship features faster, and sleep better at night. But testing poorly—or testing the wrong things—wastes time and creates a false sense of security.

This guide covers the mental models, terminology, and strategies you need to build an effective testing approach for JavaScript and TypeScript projects.

Testing Models: Pyramid vs. Trophy

Two models dominate conversations about how to distribute your testing effort. Neither is universally correct—context determines which serves you better.

The Testing Pyramid

The traditional pyramid, popularized by Mike Cohn, suggests:

        /\
       /  \        E2E (few)
      /----\
     /      \      Integration (some)
    /--------\
   /          \    Unit (many)
  --------------

The argument: Unit tests are fast, cheap, and isolated. Write many of them. Integration tests verify components work together—write fewer. E2E tests are slow, flaky, and expensive—use sparingly for critical paths.

When the pyramid fits:

  • Backend services with complex business logic
  • Libraries and utilities where functions have clear inputs/outputs
  • Systems where unit boundaries are well-defined
  • Teams that need fast CI feedback loops

The Testing Trophy

Kent C. Dodds proposed the "testing trophy" as an alternative, particularly for frontend applications:

         🏆
       E2E (few)
    ---------------
    |  Integration |  (most)
    ---------------
     |   Unit    |    (some)
      -----------
        Static        (TypeScript, ESLint)

The argument: Integration tests give you the most confidence per line of test code. They test components as users experience them, catch issues that unit tests miss, and are more resilient to refactoring than E2E tests.

When the trophy fits:

  • Frontend applications (React, Vue, Svelte)
  • Applications where user interactions span multiple components
  • Codebases with TypeScript (static analysis catches many unit-level bugs)
  • Teams prioritizing confidence over speed

Choosing Your Model

Don't treat these as religions. Most real-world projects blend both:

Project Type Recommended Distribution
UI-heavy frontend Trophy (integration-heavy)
Business logic library Pyramid (unit-heavy)
API service Balanced (unit + integration)
Full-stack app Both: unit for logic, integration for UI, E2E for critical paths

Types of Tests

Unit Tests

Unit tests verify a single "unit" of code in isolation—typically a function, method, or class. They mock or stub external dependencies.

// src/utils/price.ts
export function calculateDiscount(price: number, percentage: number): number {
  if (percentage < 0 || percentage > 100) {
    throw new Error('Percentage must be between 0 and 100');
  }
  return price * (1 - percentage / 100);
}

// src/utils/price.test.ts
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', () => {
    expect(calculateDiscount(50, 0)).toBe(50);
  });

  it('throws for invalid percentage', () => {
    expect(() => calculateDiscount(100, 150)).toThrow();
  });
});

Characteristics:

  • Fast (milliseconds per test)
  • Deterministic (no external dependencies)
  • Pinpoint failures to specific functions
  • Can become tightly coupled to implementation details

Integration Tests

Integration tests verify that multiple units work together correctly. In frontend contexts, this often means rendering a component and testing user interactions.

// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('submits credentials and shows success message', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn().mockResolvedValue({ success: true });

    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'password123',
    });
    expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
  });
});

Characteristics:

  • Test real user workflows
  • More confidence than unit tests
  • Slower than unit tests (but still fast)
  • May require more setup (providers, mocking network calls)

End-to-End (E2E) Tests

E2E tests verify entire application flows through a real browser, interacting with your actual deployed (or locally running) application.

// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';

test('complete checkout flow', async ({ page }) => {
  await page.goto('/products');

  await page.click('[data-testid="product-1"] >> text=Add to Cart');
  await page.click('text=View Cart');

  await expect(page.locator('.cart-item')).toHaveCount(1);

  await page.click('text=Checkout');
  await page.fill('#email', '[email protected]');
  await page.fill('#card-number', '4242424242424242');
  await page.click('text=Complete Purchase');

  await expect(page).toHaveURL(/\/confirmation/);
  await expect(page.locator('h1')).toContainText('Thank you');
});

Characteristics:

  • Highest confidence (tests what users actually experience)
  • Slowest to run (seconds to minutes per test)
  • Most prone to flakiness
  • Requires running application infrastructure

What Belongs Where?

Use this decision tree:

Is it pure logic with clear inputs/outputs?
  └─ Yes → Unit test

Does it involve multiple components working together?
  └─ Yes → Integration test

Is it a critical user journey that must never break?
  └─ Yes → E2E test

Is it testing implementation details (private methods, internal state)?
  └─ Yes → Don't test it directly

Examples by Category

Test This With... Examples
Unit tests Utility functions, data transformers, validation logic, state reducers, custom hooks (logic only)
Integration tests Form submission, component interactions, API integration (with mocked network), context providers
E2E tests Checkout flow, authentication, payment processing, critical business workflows

When NOT to Test

Testing has diminishing returns. Skip testing when:

Testing Implementation Details

If refactoring your code (without changing behavior) breaks your tests, you're testing implementation details.

// ❌ Bad: tests implementation
it('calls the internal _validateEmail method', () => {
  const spy = vi.spyOn(form, '_validateEmail');
  form.submit();
  expect(spy).toHaveBeenCalled();
});

// ✅ Good: tests behavior
it('shows error for invalid email', async () => {
  await user.type(emailInput, 'not-an-email');
  await user.click(submitButton);
  expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});

Trivial Code

Don't test simple pass-through functions or code that's obviously correct:

// Don't bother testing this
export function getUserId(user: User): string {
  return user.id;
}

Third-Party Code

Don't test that libraries work as documented. Trust that React renders components, that Zod validates schemas, that Lodash sorts arrays.

// ❌ Don't do this
it('sorts array correctly', () => {
  expect(_.sortBy([3, 1, 2])).toEqual([1, 2, 3]);
});

Rapidly Changing UI

During early development or heavy iteration, E2E tests become maintenance burdens. Wait until interfaces stabilize.

100% Coverage Obsession

Coverage metrics measure lines executed, not correctness. 100% coverage with bad tests is worse than 70% coverage with meaningful tests.

Testing Tools Covered in This Section

This section covers the major testing tools in the JavaScript ecosystem:

Tool Type Best For
Vitest Unit/Integration Vite projects, modern ESM codebases
Jest Unit/Integration Established projects, broad ecosystem
Playwright E2E Cross-browser testing, complex workflows
Cypress E2E Developer experience, component testing

Additional topics:

Getting Started

If you're new to testing:

  1. Start with integration tests for your most important user flows
  2. Add unit tests for complex business logic and utilities
  3. Add E2E tests for critical paths (checkout, auth, main features)
  4. Use TypeScript as your first line of defense—it catches many bugs before tests run

If you're choosing tools for a new project:

See Also

Last updated: March 23, 2026