Skip to main content
Lifecycle progress
intermediatePage 8 of 18

Phase 7: Testing

Prove the code works and stays working. The testing pyramid, the types of tests, and why 100% coverage is a misleading goal.

Phase 7: Testing

In one line: Tests prove your code works, document what it's supposed to do, and let you refactor without fear. Without them, every change is a gamble.

In plain English

Testing is the practice of writing code that checks your code. You write a function that adds two numbers. You write a test that calls it with 2, 3 and expects 5. The test runs automatically every time you save or push. If you ever break the function, the test fails and tells you. Multiply that by a few hundred tests and you have a safety net that lets you change code confidently.

This page is self-contained for Phase 7. You get the pyramid, all three test types with examples, TDD, coverage pitfalls, tools, and anti-patterns — enough to test a real project. The foundations testing page goes wider (contracts, snapshots, property-based, CI YAML); links at the bottom are optional.

Why test?

  • Catch bugs before users do.
  • Enable refactoring. Tests are scaffolding that lets you change code confidently.
  • Document behavior. A good test explains what code is supposed to do.
  • Prevent regressions. Bugs that come back are especially demoralizing.

The testing pyramid

Reading this diagram: Stacked top-down because the shape is the point — narrow at the top (few slow E2E tests, in orange/red), wide at the bottom (many fast unit tests, in green). Inverting the pyramid (many slow E2E tests) makes CI take hours and tests flake constantly.

Behavior, not implementation: assert what users/callers observe (HTTP status, rendered text, return value) — not internal method calls or private state. A good test fails only when behavior breaks; refactor-friendly tests survive rewrites. In PR comments you'll hear "this is testing implementation details" or "assert on behavior, not internals" — same rule.

Quick jargon for Phase 7

Happy path = normal success case. Regression test = test that locks a fixed bug. Flake = test that fails randomly. Green CI = all checks passed. Smoke test = one E2E on the critical flow before/after deploy. Red-green-refactor = TDD cycle (failing test → pass → clean up).

Test types in depth

Unit tests

Test individual functions or components in isolation. Mock external dependencies. Run in milliseconds.

import { describe, it, expect } from 'vitest';
import { formatPrice } from './format-price';

describe('formatPrice', () => {
it('formats USD with 2 decimals', () => {
expect(formatPrice(1234.5, 'USD')).toBe('$1,234.50');
});

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

In English: Each it(...) block is one test case. describe groups related tests under one heading. expect(x).toBe(y) is an assertion — if x doesn't equal y at runtime, the test fails. No mocks here because formatPrice is pure (no DB, no network).

Integration tests

Test how pieces work together. API endpoint + database. Component + state management.

import { test, expect } from 'vitest';
import { app } from './app';

test('POST /users creates a user', async () => {
const response = await app.request('/users', {
method: 'POST',
body: JSON.stringify({ name: 'Tony', email: 'tony@example.com' }),
});

expect(response.status).toBe(201);
const user = await response.json();
expect(user.id).toBeDefined();
});

In English: Fire a real HTTP POST /users at the in-process app, then assert it responded with 201 Created (the standard HTTP status for "resource created") and that the response body has an id. No browser, no real network — but the app's routing, validation, and DB writes all execute. That's what makes it an integration test rather than a unit test.

End-to-end (E2E) tests

Drive a real browser through real user flows. Find bugs that span the full stack.

import { test, expect } from '@playwright/test';

test('user can sign up and create a project', async ({ page }) => {
await page.goto('/signup');
await page.fill('[name=email]', 'tony@example.com');
await page.fill('[name=password]', 'SecurePass123!');
await page.click('button[type=submit]');

await expect(page).toHaveURL('/dashboard');

await page.click('text=New Project');
await page.fill('[name=name]', 'My First Project');
await page.click('text=Create');

await expect(page.locator('h1')).toContainText('My First Project');
});

In English: Playwright launches a real headless Chrome, navigates to /signup, types into form inputs, clicks Submit, then asserts the user landed on /dashboard. Then it creates a project and checks the page heading. Each await is a real browser action; the test reads almost like an English script of what a user would do.

Other test types

  • Visual regression tests — Take screenshots; compare against baseline. Catches unintended visual changes.
  • Performance tests — Measure response times under load (k6, Artillery, Gatling).
  • Accessibility tests — Verify WCAG compliance (axe-core).
  • Security tests — SAST (Semgrep, CodeQL), DAST (OWASP ZAP), SCA (Snyk, Dependabot).

Test-Driven Development (TDD)

A discipline where you write the test first:

  1. Write a failing test (red).
  2. Write the minimum code to make it pass (green).
  3. Refactor.
  4. Repeat.

Engineers shorthand the cycle red-green-refactor. TDD enforces small, testable units and high coverage. It's valuable but not universally adopted; many great codebases are tested after the fact (test-after or characterization tests when adding tests to legacy code).

Coverage is misleading

"100% test coverage" doesn't mean bug-free. You can have 100% coverage and still miss critical bugs:

function divide(a: number, b: number): number {
return a / b;
}

// Test: expect(divide(10, 2)).toBe(5); // 100% coverage!
// But: divide(10, 0) returns Infinity, not an error.

Coverage is a minimum signal. The real question: do your tests cover the cases that would matter to users?

Highlight: the 80/20 rule for beginner test suites

For a beginner project, you don't need a perfect test pyramid. You need some tests for the parts that matter:

  1. One E2E test for your most important user flow (signup → core action).
  2. Unit tests for any function with complex logic (calculations, parsers, validators).
  3. No tests for trivial code (a function that just calls another function).

This gives you ~20% of the testing effort for ~80% of the value. As your project grows, you can fill in the rest.

Common anti-patterns

  • No tests: "I'll add them later." (You won't.)
  • Only happy-path tests: No tests for errors, edge cases, or invalid input.
  • Tests that test implementation, not behavior: Refactoring breaks tests even when behavior is correct.
  • Flaky tests: Sometimes pass, sometimes fail. Erode trust until everyone ignores CI.
  • Massive E2E test suites: Slow CI, hard to debug, eventually abandoned.
  • Snapshot tests for everything: Just commits the current output as "correct"; catches nothing meaningful.

Tools in 2026

ToolWhat it's for
VitestDominant test runner for new JS/TS projects (replaces Jest).
PlaywrightDominant E2E framework.
Testing LibraryLightweight DOM testing.
MSWMock API requests realistically.
k6Load testing.
Chromatic / PercyVisual regression.
StorybookComponent development + interaction testing.

Testing AI-generated code in this phase

AI assistants are useful for scaffolding test files — describe blocks, factory setup, Playwright boilerplate. They are not reliable for deciding what to assert. The failure mode: tests that pass while encoding the bug (happy-path only, or expect on whatever the broken code returns).

Discipline for Phase 7:

  1. You define the cases — especially unhappy paths (400 on bad input, empty list, unauthorized).
  2. Bug fix = failing test first — reproduce in a test, then fix code until green.
  3. Read AI-generated assertions — reject tests that mock the function under test or assert implementation details.
  4. Run the suite locally before opening a PR — same verification habit as debugging AI-generated code.

Common mistakes

Where people commonly trip up
  • Testing the implementation, not the behavior. Asserting that a component calls useState three times means the test breaks the day you refactor it. Test what the user (or caller) sees — rendered text, returned values, network requests made — so refactors stay free.
  • Stuffing every test into the E2E layer. Playwright tests are tempting because they "test the real thing," but they're slow, flaky, and brutal to debug. Push logic down into unit/integration tests where each failure points to one file, not to "checkout flow broken somewhere."
  • Mocking the thing under test. If your test mocks the function it's supposed to verify, it's testing the mock. Mock the dependencies around your subject (DB, HTTP, time, random), then let the real code run.
  • Snapshot-testing everything. Snapshots default to toMatchSnapshot() recording whatever output you produced — including bugs — and then asserting "stay this way." A snapshot only tests what its reviewer thought about; for most components, an explicit expect(...) is better.
  • Asking AI to "add tests" to a file. It will obediently add tests that re-assert what the code does, including its bugs. Tests have to encode intent, which the AI doesn't know. Use it for boilerplate scaffolding; write the assertions yourself.

Page checkpoint

Checkpoint Quiz

Did testing stick?

Required

Going deeper (optional)

What's next

→ Continue to Phase 8: Code Review where we add a second pair of human eyes to catch what tests miss.