Once you understand the value of automated tests, the next question is inevitable: which tests should you write, and in what proportion? An application can be checked at very different levels, from a single function up to a simulated user clicking through the browser. Each level has different costs and benefits. The testing pyramid is the best-known model for reasoning about this balance.
The model
The pyramid image, popularised by Mike Cohn, divides tests into three levels:
- At the base, many unit tests: they check individual functions or classes, and are fast and cheap.
- In the middle, a moderate number of integration tests: they check that several components work together, for example the code and the database.
- At the top, few end-to-end tests: they simulate a real user moving through the whole application.
The shape expresses a simple idea: the higher you go, the slower, costlier to maintain and harder to diagnose tests become, so it pays to have fewer of them.
Comparing the levels
| Feature | Unit | Integration | End-to-end |
|---|---|---|---|
| Speed | Milliseconds | Seconds or less | Seconds or minutes |
| What they check | Isolated logic | Collaboration between parts | The user's full flow |
| Diagnostic precision | High | Medium | Low |
| Stability | High | Medium | More brittle |
| Confidence in the real system | Limited | Good | High |
The table shows why no level is best in absolute terms. Unit tests tell you exactly where an error is, but do not guarantee that the pieces fit together. End-to-end tests give a lot of confidence, but when they fail it can be hard to understand why.
Integration tests
The middle level is often the most neglected, yet it catches a very common category of error: everything works on its own, but not together. Typical examples:
- an SQL query that is syntactically correct but does not return the expected data with the real schema;
- a service expecting dates in one format and receiving another;
- a configuration missing in the test environment but present in development.
For these tests it is worth using real components in a controlled version: a real database started in a container, with data prepared for each test and cleaned up at the end. Simulating the database with a mock, in these cases, would risk hiding exactly the errors you want to find.
End-to-end tests
An end-to-end test for an online shop might do this: open the home page, search for a product, add it to the cart, complete payment with test data and check the order confirmation. Tools such as Playwright, Cypress or Selenium drive a real browser to carry out these steps.
They are valuable for critical paths, the ones that halt the business if they break: sign-up, login, purchase. But they should be chosen sparingly, for three reasons:
- they are slow, and a suite that takes an hour gets run less often;
- they are sensitive to details such as load times and animations, and tend to fail intermittently;
- when they fail, the cause can be anywhere in the stack.
The ice cream cone anti-pattern
The opposite of the pyramid is the so-called "ice cream cone": very few unit tests, a few integration tests and a large number of end-to-end tests or, worse, manual checks. It is common in projects where tests were added after the fact, starting from what could be seen from outside. The result is a slow, unstable suite that the team learns to ignore.
Getting out of it takes patience: every time an end-to-end test finds a bug, it is worth asking whether the same error could have been caught by a lower-level test, and writing it.
Other models
The pyramid is not dogma. Some developers propose different shapes, such as the "testing trophy", which gives more weight to integration tests, especially for frontend applications where much of the logic lies in the interaction between components. The point common to all models is the same: choose deliberately where to invest, instead of piling up tests at random.
How to apply it in practice
Some concrete guidance for a real project:
- write unit tests for business logic: calculations, rules, validations, data transformations;
- write integration tests for the system's boundaries: databases, message queues, external APIs simulated realistically;
- write end-to-end tests only for flows that absolutely must not break;
- run unit tests on every change, integration tests on every push, end-to-end tests at least before every release;
- treat flaky tests as bugs: fix them or remove them, do not ignore them.
In short
- The pyramid suggests many unit tests, a moderate number of integration tests and few end-to-end tests.
- The higher you go, the slower, costlier and harder to diagnose tests are, but the more confidence they give in the real system.
- Integration tests catch errors between components and preferably use real, controlled resources.
- Reserve end-to-end tests for the application's critical paths.
- Avoid the "ice cream cone": push checks down a level whenever you can.