The test pyramid

The shape is a consequence of cost, measured on this project: five in-process guards in 1.9 seconds, two container-based ones in 54.

5 min read🧪 Testing Java Services

The pyramid says: many fast tests at the bottom, fewer and slower ones as you go up. It is drawn in every testing article and the drawing is not the useful part — the reason for the shape is, and the reason is a number you can measure on your own project in about a minute.

Measure your own, first

Here is this site's test suite, timed guard by guard:

plaintext
check:            0.3s
learner:test:     0.8s
search:test:      0.2s
blog:test:        0.4s
sql:test:         0.2s
java:test:       19.5s
lesson:run:test: 34.5s

Five of those run pure JavaScript against in-process data: 1.9 seconds together. Two of them start Docker containers and compile and run Java: 54 seconds.

That is a 28× difference, and nothing about it is unusual. It is the whole argument for the shape:

  • The bottom is where you can afford many tests, because a hundred of them cost a second.
  • The top is where each test is expensive, so you want few — and you want each one to earn its place by covering something the layer below cannot.

The pyramid is not an aesthetic preference about test counts. It is a consequence of what things cost.

The three levels, and what only each can catch

LevelSpeedCatches what nothing below it can
Unitmillisecondsa rule computed wrongly
Integrationsecondsyour code meeting something real — a database, a broker, an HTTP client
End to endtens of seconds to minutesthe pieces wired together, and configuration

The middle row is the one that gets skipped and the one that pays. A unit test of a repository method proves nothing about whether the SQL is valid — @DataJpaTest against a real Postgres in a container does, and that is a class of bug unit tests structurally cannot reach.

The top row's real subject is configuration. Most end-to-end failures are not logic; they are a missing environment variable, a security rule in the wrong order, a serialiser that behaves differently in the packaged jar. Those exist nowhere below.

The shapes teams actually build

Three of them, and each is a recognisable failure:

The ice-cream cone. Almost everything end to end, little underneath. Usually arrives when a QA team owns tests and engineers do not write them. It catches real bugs and does it slowly, flakily, and hours after the commit — so it tells you something is broken rather than this line is wrong.

The hourglass. Many unit tests, many end-to-end tests, nothing in between. Extremely common, and it happens because the middle is the awkward one: too slow to feel like a unit test, too fiddly to set up. Then a query is wrong and only the browser test notices, three minutes later.

The cupcake. Everything, everywhere, several times. Every rule tested as a unit, again through the service, again through the API. It passes every review and takes forty minutes, and the duplication means one change breaks tests at three levels.

The number that actually matters

Not the ratio. How long until a developer knows they broke something.

Under ten seconds, people run the tests constantly and find out while the change is still in their head. Past a few minutes they stop running them and wait for CI. Past ten minutes they push and go to lunch, and the feedback arrives attached to somebody else's commit as well as theirs.

So the useful tuning is not "more unit tests" — it is:

  • Split the suite. Fast tests on every save; the slow ones on push or in CI. Maven profiles or JUnit tags are enough.
  • Run in parallel. JUnit 5 does it with a property; Testcontainers reuse turns a container start into a container connect.
  • Make the slow ones fewer, not faster. A container start has a floor. The lever is how many times you pay it.

That last point is why this site's own suite has exactly two slow guards. They cost 54 seconds and they are the only place a Java fixture or a lesson's predict output can be proven against a real JVM — so they are worth their cost, and there is no third one.

Where each test belongs

A rule of thumb that resolves most cases: write the test at the lowest level that could actually fail for this reason.

  • A discount threshold — a unit test. Nothing about a database makes it more true.
  • A repository query — an integration test with a real database. It is SQL; only a database can say.
  • Authentication rules — an integration test at the filter chain, because order is the behaviour.
  • "Can a customer complete checkout" — one end-to-end test. One, not twelve.
Progress is saved on this device and to your account when signed in.