QA
Lesson 1 of 8About 3 min readSuggest an edit

The testing pyramid

The testing pyramid is a rule of thumb for balancing test types: many fast unit tests at the bottom, fewer integration tests in the middle, and a small number of end-to-end tests at the top.

The layers

Unit tests check one small piece of logic in isolation: a function, a class, a pricing rule. They run in milliseconds, point straight at the broken line, and are cheap to write. They cannot tell you whether the pieces fit together.

Integration tests check that components work together for real: your code with a real database, a message queue, or another service’s API contract. They catch wrong SQL, broken serialisation and misconfigured wiring, which unit tests miss.

End-to-end (E2E) tests drive the whole system like a user would, often through a browser. They give the most confidence that a journey works, but they are slow, expensive to maintain and prone to flakiness. Reserve them for the few journeys that must never break, such as sign-up and checkout.

The pyramid is about cost, not dogma. Some teams prefer a “testing trophy” that weights integration tests more heavily. The underlying rule is the same: push each check down to the cheapest level that can catch the bug.

Test doubles

When a dependency is slow, non-deterministic or has side effects, replace it with a double.

  • A stub returns canned answers: “the exchange-rate service returns 1.1”.
  • A mock also verifies how it was called: “the email service was called once with this address”.
  • A fake is a working lightweight implementation, such as an in-memory repository.

Prefer fakes and real implementations where you can. Heavy mocking couples tests to how the code works rather than what it does, so a harmless refactor breaks dozens of tests. Mock at the boundaries you do not own (payment gateways, third-party APIs), not your own classes.

// Tests behaviour through a fake clock, not by mocking internals
it("expires a session after 30 days", () => {
  const clock = new FakeClock("2026-01-01T00:00:00Z");
  const sessions = new SessionStore(clock);
  const id = sessions.create("user-1");
  clock.advanceDays(30);
  expect(sessions.get(id)).toBeNull();
});

Flaky tests

A flaky test passes and fails without any code change. Common causes are timing assumptions (sleep(500)), shared state between tests, test order dependence, real network calls, and unseeded randomness or real clocks.

Flaky tests are worse than no tests, because people learn to ignore red builds. Fix the cause (wait for a condition instead of sleeping, isolate state, inject clocks and random seeds) or quarantine the test while you do. Never make “retry until green” the fix.

What makes a good test

  • It fails for exactly one reason, and its name says what behaviour broke.
  • It follows Arrange, Act, Assert, and the three parts are easy to spot.
  • It checks observable behaviour: return values, stored state, messages sent.
  • It is deterministic and independent of other tests.
  • You have seen it fail. A test that has never failed might not test anything. Writing it before the code (test-driven development) guarantees you see it fail.

Next: Designing test cases

Equivalence partitioning, boundary values, decision tables and state transitions, with fewer tests that find more bugs.