Software testing basics: the test pyramid and what to test

Tests let you change code without fear. Learn which kinds to write, how many of each and how to keep them useful.

A pyramid of unit, integration and end-to-end tests

Automated tests are programs that check your program. They catch bugs early, document behaviour and give you the confidence to change code. Without them, every change is a risk and every release is a guess.

Types of tests

Unit tests

  • Test a small piece (a function or class) in isolation.
  • Very fast, so you can run thousands.
  • Great for logic, calculations, validation and edge cases.

Integration tests

  • Test that several parts work together: your code with a real database, a queue or an HTTP layer.
  • Slower, but they catch the problems unit tests miss, such as wrong SQL or mismatched formats.

End-to-end (E2E) tests

  • Drive the whole app like a user: open the browser, log in, buy something.
  • Most realistic, but slowest and most fragile. Keep them few and focused on the critical journeys.

Other useful kinds: contract tests for APIs, performance and load tests, security tests, accessibility checks, and snapshot or visual tests.

The test pyramid

A healthy suite has many unit tests, fewer integration tests and very few E2E tests. Fast, cheap tests form the wide base. Slow, expensive ones sit at the top. An upside-down pyramid (mostly E2E) is slow, flaky and expensive to maintain.

The pyramid is a guide, not a law. Some modern teams favour more integration tests for web apps because they give a good balance of confidence and speed.

Anatomy of a good test

Use the Arrange, Act, Assert pattern.

test("applies a 10% discount for orders over 100", () => {
  // Arrange
  const order = { subtotal: 120 };

  // Act
  const total = calculateTotal(order);

  // Assert
  expect(total).toBe(108);
});

Good tests are:

  • Fast and independent (they can run in any order).
  • Repeatable: same result every time, no dependence on the clock, network or random data unless controlled.
  • Clear: the name states the behaviour, and a failure tells you what broke.
  • Focused: one idea per test.

What to test

  • The main behaviour (the happy path).
  • Edge cases: empty input, zero, negative numbers, very large values, missing fields, duplicates.
  • Error cases: invalid input, failures from dependencies.
  • Business rules and anything that previously caused a bug.
  • Security-relevant rules: permissions, ownership checks, input validation.

You do not need to test the framework or trivial getters. Test behaviour, not implementation details, so refactoring does not break tests.

Test doubles: mocks, stubs and fakes

To isolate code from slow or unpredictable things (network, payment provider, clock), replace them with test doubles:

  • Stub: returns fixed answers.
  • Mock: also records how it was called, so you can assert on it.
  • Fake: a simple working version, such as an in-memory database.

Use them sparingly. Too many mocks make tests that pass while the real system is broken. Prefer real components (a real test database) where it is practical, and keep a few integration tests for the seams.

Test-driven development (TDD)

TDD is a rhythm:

  1. Red: write a failing test for the next small behaviour.
  2. Green: write the simplest code that passes.
  3. Refactor: clean up while tests protect you.

It is not required, but trying it teaches you to design code that is easy to test. Writing a failing test first for every bug you fix is an excellent habit.

Coverage: a tool, not a goal

Code coverage shows which lines the tests ran. Low coverage reveals untested areas. But high coverage does not prove good tests, since a test can run code without checking anything. Do not chase 100%. Look at coverage of critical logic, and review whether tests actually assert meaningful things.

Keep tests reliable

  • Flaky tests (sometimes pass, sometimes fail) destroy trust. Fix or delete them quickly. Common causes: timing, shared state, order dependence and real network calls.
  • Reset state between tests and avoid sharing mutable data.
  • Control time and randomness.
  • Run tests in CI on every push and pull request, and make the build fail when tests fail.
  • Keep the suite fast. A slow suite is run less.

Getting started

  1. Pick the standard test framework for your language (pytest, JUnit, Jest/Vitest, Go's testing package, and so on).
  2. Write a few unit tests for existing logic.
  3. Add one integration test for a main flow, then one E2E test for the most important user journey.
  4. Add a test with every bug fix.
  5. Run it all in CI.

Takeaway: write many fast unit tests, some integration tests and a few E2E tests. Test behaviour and edge cases, use mocks carefully, keep tests reliable and run them automatically on every change.


Programming roadmaps series, Part 2 (languages and design): 5 of 5. Previous: Clean code and code review habits that make you a better developer.

Keep reading