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.
// 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.
// 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.
// 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.
[E2E]
[Integration] <- Widest layer
[Unit Tests]
[Static Analysis]When to Use Each
| Scenario | Test Type |
|---|---|
| formatDate() utility function | Unit |
| Slug generation algorithm | Unit |
| React component renders correctly | Unit (React Testing Library) |
| API route validates input and creates record | Integration |
| Database query returns correct results | Integration |
| User signup → email verification → login flow | E2E |
| Checkout flow from cart to confirmation | E2E |
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
// 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');
});