Testing Fundamentals

Types of Tests: Unit, Integration, E2E

Understand when to write unit, integration, and end-to-end tests — and why the distinction matters.

Three Types of Tests

Not all tests are equal. Choosing the right type for each scenario determines whether your test suite is a valuable safety net or a slow, brittle burden.

Unit Tests

Unit tests verify a single function, class, or component in complete isolation. All external dependencies are replaced with mocks.

Characteristics: Very fast (milliseconds), very reliable, very cheap to write and maintain.

When to use: Business logic, utility functions, data transformations, algorithms, pure functions.

typescript
// A pure unit test — no database, no network, no filesystem
describe('formatPrice', () => {
  it('formats cents as dollars with two decimal places', () => {
    expect(formatPrice(1999)).toBe('$19.99');
  });

  it('handles zero', () => {
    expect(formatPrice(0)).toBe('$0.00');
  });

  it('handles large amounts', () => {
    expect(formatPrice(100000)).toBe('$1,000.00');
  });
});

Integration Tests

Integration tests verify that multiple units work correctly together. They use real dependencies where possible — real database queries, real HTTP calls (to a test server), real file system.

Characteristics: Slower than unit tests (seconds), more realistic, catch interaction bugs.

When to use: API routes, database operations, service-to-service communication.

typescript
// Integration test — uses real database
describe('POST /api/users', () => {
  beforeEach(async () => {
    await db.users.deleteAll(); // Clean state before each test
  });

  it('creates a user and returns 201', async () => {
    const res = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify({ email: 'test@example.com', password: 'SecurePass1!' }),
    });

    expect(res.status).toBe(201);
    const user = await res.json();
    expect(user.email).toBe('test@example.com');
    expect(user.password).toBeUndefined(); // Never expose passwords
  });
});

End-to-End Tests

E2E tests control a real browser and simulate real user actions against your running application.

Characteristics: Slow (seconds to minutes), most realistic, catch the bugs users would actually encounter.

When to use: Critical user flows: signup, login, checkout, core feature paths.

typescript
// Playwright E2E test
test('user can sign up and see dashboard', async ({ page }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('SecurePass1!');
  await page.getByRole('button', { name: 'Create Account' }).click();

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome, test@example.com')).toBeVisible();
});

The Testing Trophy

Kent C. Dodds' "testing trophy" suggests a modification to the classic pyramid: write MORE integration tests than the traditional pyramid suggests. Integration tests give the best return per test — more realistic than unit tests, faster than E2E.

text
        [E2E]
      [Integration]   <- Widest layer
    [Unit Tests]
  [Static Analysis]

When to Use Each

ScenarioTest Type
formatDate() utility functionUnit
Slug generation algorithmUnit
React component renders correctlyUnit (React Testing Library)
API route validates input and creates recordIntegration
Database query returns correct resultsIntegration
User signup → email verification → login flowE2E
Checkout flow from cart to confirmationE2E

Key Takeaways

  • Unit tests verify isolated functions — fast, cheap, many of them
  • Integration tests verify how components work together — more realistic, medium speed
  • E2E tests verify complete user flows in a real browser — slowest, highest confidence, fewest needed
  • The testing trophy: favor integration tests over unit tests where possible — better confidence per test
  • Never test implementation details — test behavior (what the code does, not how it does it)

Example

typescript
// Three tests for the same feature at different levels

// Unit test — pure function in isolation
it('slugifies a title', () => {
  expect(slugify('Hello World!')).toBe('hello-world');
});

// Integration test — API route with real database
it('creates a post with auto-generated slug', async () => {
  const res = await POST({ body: { title: 'Hello World!' } });
  const post = await res.json();
  expect(post.slug).toBe('hello-world');
});

// E2E test — user creates a post in the browser
test('user creates and views a post', async ({ page }) => {
  await page.goto('/posts/new');
  await page.getByLabel('Title').fill('Hello World!');
  await page.getByRole('button', { name: 'Publish' }).click();
  await expect(page).toHaveURL('/posts/hello-world');
});
Try it yourself — TYPESCRIPT