The more code you have, the harder it is to know which tests to write more of, which fewer, and where the line between levels runs. The test pyramid is a simple model that puts everything in its place.
Why you need the pyramid
Without a model, teams often write either only unit tests (and miss integration problems) or only e2e tests (slow feedback, brittle suites). Both extremes are expensive to maintain.
The pyramid says: at the base — many fast, isolated tests; at the top — few slow, end-to-end ones. The higher the level, the more expensive the run and the harder the diagnosis. So you only go up for what a lower level cannot check.
Three layers
┌───────┐
│ e2e │ ← few, slow
├───────┤
│ integ │ ← moderate
├───────┤
│ unit │ ← many, fast
└───────┘
| Level | What it checks | Speed |
|---|---|---|
| Unit | one class / function, no external dependencies | milliseconds |
| Integration | several components + real infrastructure | seconds |
| E2e | full path from UI / API to the database | tens of seconds |
Unit tests: isolation and speed
A unit test checks one class in full isolation — no Spring context, no database, no network. Dependencies are replaced with stubs.
Short formula: one test — one behavior scenario.
class DiscountServiceTest {
private final DiscountService service = new DiscountService();
@Test
void appliesDiscountWhenTotalExceedsThreshold() {
var total = service.apply(new Money(1000), CustomerTier.GOLD);
assertThat(total).isEqualTo(new Money(900));
}
@Test
void noDiscountBelowThreshold() {
var total = service.apply(new Money(500), CustomerTier.GOLD);
assertThat(total).isEqualTo(new Money(500));
}
}
Good candidates for unit testing: business rules, edge cases, calculations, mapping, validation. Poor candidates: code stitched to the framework or infrastructure — that needs the next level.
Integration tests: real infrastructure
An integration test brings up part (or all) of the Spring context and works with real dependencies — a database, a message broker, an external HTTP service.
@SpringBootTest
@Transactional
class OrderRepositoryIT {
@Autowired
OrderRepository repository;
@Test
void savesAndFindsOrder() {
var order = repository.save(new Order(CustomerId.of("c-1"), List.of()));
assertThat(repository.findById(order.id())).isPresent();
}
}
For a real database in tests, use Testcontainers — it brings up PostgreSQL (or another engine) in a Docker container and stops it after the suite. Configuring Spring tests is covered in more detail in the Testing in Spring section.
Integration tests are slower than unit ones, so you write them for critical paths: repositories, command handlers, HTTP clients to external services.
E2e tests: the full request path
An e2e test goes the whole way: HTTP request → controller → business logic → database → HTTP response. The environment is as close to production as possible.
@SpringBootTest(webEnvironment = RANDOM_PORT)
class CreateOrderE2eTest {
@Autowired
TestRestTemplate http;
@Test
void createsOrderAndReturns201() {
var body = new CreateOrderRequest(customerId, items);
var response = http.postForEntity("/orders", body, Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
}
E2e tests are the most expensive: they bring up the whole context, start slowly, and make failures hard to localize. You write few of them — only for key user scenarios.
What to test and what not to
Worth testing:
- business rules and edge cases (discounts, limits, status transitions)
- data mapping between layers
- handling of invalid input
- interaction with the database on critical paths
Not worth testing:
- getters, setters, constructors (obvious code with no logic)
- framework configuration — Spring is already tested on its own
- implementation details that may change (internal private methods)
Rule: a test should break when behavior changes, and stay green through a refactoring that does not change behavior.
In short
- The pyramid: many unit → moderate integration → few e2e.
- Unit tests — fast, isolated, no Spring context; cover business logic.
- Integration tests — with real infrastructure (
Testcontainers,@SpringBootTest); for repositories and HTTP clients. - E2e tests — the full request path; write them only for key scenarios.
- Test behavior and business rules, not getters and not the framework.
- The higher the level, the more expensive the run and the fewer tests you need.
What to read next
- Integration tests in detail —
Testcontainers, transactions, isolation of test data. - Mocks and external dependencies — when to use
WireMockand when a real stub. - Testing in Spring —
@WebMvcTest,@DataJpaTest, slices and context configuration.