Lesson 1 of 5
Testing: JUnit, Mockito and Slices
"All models are wrong, but some are useful." — George E. P. Box, Robustness in the Strategy of Scientific Model Building (1979)
A mock is a model of a collaborator, and Box's warning applies exactly: it is wrong by construction, and whether that matters depends entirely on what you are using it for. A test suite made of accurate models tells you your system works. A test suite made of convenient models tells you your mocks agree with each other, which is a different claim and a much weaker one.
That is the thread running through this chapter. Java has excellent testing tools — JUnit 5, Mockito, and Spring's test slices — and all three are easy to use in a way that produces a large, green, slow suite that catches nothing. So rather than tour the annotations, we will build a miniature of each, because the mechanism explains the failure modes.
By the end you will have written a test runner, a mocking framework in forty lines, and a cost model for Spring's context cache that explains why your build takes eleven minutes.
Your test suite is green and your service is broken in production. What kind of test would have caught it, and why did the ones you have not?
What each level of test actually buys
Before the tools, the decision they serve: what to test where.
Click Run to compile and execute the code.
The two shapes are the argument. A pyramid-shaped suite runs 479 tests in under two minutes; an ice-cream-cone suite runs 250 tests and takes far longer, because the count is dominated by the slowest level.
But the more useful column is the last one: what a failure tells you. A failing unit test names the method and the branch. A failing end-to-end test says "checkout is broken", which could be a bug, a timing issue, or a flaky selector. A slow test that localises badly costs you twice — once to run and again every time someone has to investigate it.
So the rule is not "mostly unit tests" as a quota. It is: push each test down to the cheapest level that can still falsify the thing you are worried about. Branch logic belongs in a unit test. "Does this query actually work" cannot go there — no amount of mocking proves SQL is valid, which is why slices and Testcontainers exist.
A test runner, in miniature
JUnit's model is small: discover methods, run lifecycle hooks around each, record what threw.
Click Run to compile and execute the code.
Seven passes and one deliberate failure, with the three parameterised invocations reported as separate lines. The mechanism is unremarkable, and two of its properties are worth noticing because they are design decisions rather than conveniences.
A fresh instance per test. JUnit 5 constructs the test class again for every method, which is why independentOfTheOtherTests can assert the cart is empty. Shared mutable state between tests produces order-dependent failures, which are the worst kind to debug, so the framework removes the possibility by default.
@AfterEach runs in a finally. Cleanup happens whether the test passed, failed or threw something unexpected, because a test that leaves a resource open poisons the ones after it.
A parameterised test is N separate tests, each with its own instance and its own report line. That matters for diagnosis: one failing input is named, rather than a loop failing at an index you have to work out.
A flight simulator is a mock of an aeroplane, and it is extremely useful: you can practise an engine failure over the Atlantic without an aeroplane, an Atlantic or an engine failure. It is also wrong in every particular, and the failures it cannot teach you are exactly the ones nobody modelled — the smell of something burning, a switch that sticks in the cold, the fact that the real checklist is laminated and slippery. No airline concludes from this that simulators are bad; they conclude that you also need flight hours, and that the two are not substitutes. The mistake this chapter is about is the equivalent of certifying a pilot entirely in the simulator and being surprised on the first real landing — a suite where every collaborator is a model, every model was written by the same person who wrote the code, and nothing has ever touched a real database.
A mocking framework in forty lines
Mockito looks like magic until you see that it is a dynamic proxy that records calls.
Click Run to compile and execute the code.
That is the whole idea: a proxy that records every call and replays stubbed answers. when(...).thenReturn(...) is the stub map; verify(...) is a lookup in the call list.
Two behaviours follow directly from the implementation. An unstubbed method returns a type-appropriate default — null for objects — which is why a forgotten stub does not say "you forgot a stub" but produces a NullPointerException deep inside the code under test. (Mockito's strict stubbing, on by default with MockitoExtension, pushes back from the other side: it fails the test for stubs you declared and never used.)
Verification is about side effects. In the already paid branch there is nothing to assert about the return value that proves no email was sent — only verifyTimes("send", 0) does. That is the legitimate use of a mock: asserting that something did not happen.
The vocabulary we use here was imposed on a mess. By 2007 "mock" meant everything: a hard-coded return value, an in-memory database, an object that recorded calls. Gerard Meszaros's xUnit Test Patterns gave the five kinds distinct names — dummy (passed but never used), stub (returns canned answers), spy (a stub that records), mock (pre-programmed with expectations it verifies), and fake (a working but unsuitable-for-production implementation, like an in-memory repository) — and Martin Fowler's Mocks Aren't Stubs made the distinction famous the same year. The point was never taxonomy for its own sake: Fowler's argument was that choosing a mock over a stub is choosing to test interactions rather than state, and that this is a design decision with consequences for how much your tests resist refactoring. Two decades later most Java developers say "mock" for all five and use Mockito for all five, which is fine — but the distinction Fowler drew is exactly the one the next section is about.
When the model is wrong
Mocks fail in two directions, and both produce green suites over broken systems.
Click Run to compile and execute the code.
The first failure is the serious one. The mock returns 199900 because its author believed the API returns kobo; the real API returns naira, so the code is wrong by a factor of a hundred and the test is green. A mock encodes your belief about a collaborator, and your belief about the collaborator is precisely the thing a test with a mock in it cannot check.
The second failure is cheaper but far more common. A test that verifies the exact call sequence fails when someone adds a cache — even though the user-visible answer is identical. Its failure carries no information, so it costs maintenance and pays nothing. Mocks make interaction assertions easy, and most of the time state assertions are what you actually want.
The table of when to mock is the practical answer, and the row that matters most is the last: a mock cannot tell you your SQL is valid. Which is what slices are for.
Writing the forty-line mock surfaced something I had used for years without thinking about. My first invoke recorded every call and returned a default for anything unstubbed — and then System.out.println(mock) printed null and the call list had toString[] in it, because toString, hashCode and equals all arrive at the handler like any other method. Mockito obviously handles this, and I had never once wondered how a mock manages to print as Mock for Repo or behave sanely as a HashMap key. It is four lines and it has to be there. The wider point is the one this volume keeps finding: the forty lines that make a tool work are usually boring, and the forty lines that make it pleasant are where the thought went. Building the miniature shows you both, and reading the documentation shows you neither.
Slices, and why the build is slow
Spring tests are slow for one specific reason, and it is measurable.
Click Run to compile and execute the code.
The context cache is the entire performance story. Spring starts an application context once and reuses it for every test with the same configuration — so a suite with one configuration pays 4.5 seconds total, and a suite where each class has a slightly different set of mocked beans pays 4.5 seconds per class, which is nine minutes of startup for 120 tests that assert exactly the same things.
That is why @MockBean (and its Boot 3.4+ replacement @MockitoBean) deserves care: every distinct combination of mocked beans is a new cache key and therefore a new context. Standardising on a small number of test configurations is usually the single largest build-time win available.
The slices themselves are a trade: @WebMvcTest loads the MVC layer without your services or repositories, so it is fast and can falsify routing, binding, validation and status codes — and nothing else. @DataJpaTest loads JPA against a database and can falsify a mapping or a derived query name that does not compile into SQL. A slice is fast because it is narrow, and the narrowness is what it cannot test, not an oversight.
Three patterns separate suites people trust from suites people skip. Use the real database engine — Testcontainers, with @ServiceConnection in Boot 3.1+, so the JDBC URL is wired automatically. An H2 test cannot reproduce a PostgreSQL-specific type, an InnoDB gap-lock deadlock, or a migration that fails on the real engine, and those are three of the most expensive bugs to find in production. Assert query counts on your important endpoints, which is the only mechanism that stops an N+1 coming back. And treat a flaky test as a broken test: the moment a suite has two tests that "sometimes fail", people start re-running the build instead of reading it, and at that point every test in the suite has stopped providing information. Quarantine or delete them the same week. A smaller suite that is always believed is worth far more than a larger one that is routinely ignored.
Common Mistakes
Mocking what you are trying to verify. A mocked repository cannot tell you the query compiles, let alone returns the right rows.
Letting the mock encode your assumption. It was written by whoever misunderstood the collaborator, so it agrees with the bug.
Verifying call sequences. The test then fails on refactors that change nothing a user can see.
Using
@SpringBootTestby default. The narrowest slice that can falsify the concern is usually much faster.A different
@MockBeanset per test class. Each distinct configuration is another context startup.Reaching for
@DirtiesContext. It evicts a cached context, so everything after it pays full startup again.Testing against H2 and deploying to PostgreSQL. Different engine, different SQL, different locking.
Treating coverage as the goal. It measures lines executed, not assertions made; a test with no assertions covers fine.
Tolerating flaky tests. Two unreliable tests are enough for people to stop reading failures at all.
Sharing mutable state between tests. JUnit creates a fresh instance per method precisely to prevent order-dependent failures.
Interview Corner
@SpringBootTest with a slightly different set of @MockitoBean declarations, each one is a different cache key and pays a full startup — a hundred classes at four seconds is nearly seven minutes of pure boot. So the first move is to standardise on a handful of shared configurations and let the cache work, and to hunt for @DirtiesContext, which evicts an entry and makes everything after it pay again. The second is to push tests down: most of what is written as @SpringBootTest needs no context at all, and most of the rest needs only @WebMvcTest or @DataJpaTest. Then I would split the suite — fast tests on every commit, the slow integration layer on merge — so the feedback loop that matters is short. One thing I would not do is swap Testcontainers for H2 to save time; that trades minutes of build for the class of bug that only appears in production.Hands-on
Encode the testing judgements. Implement cheapestLevel, pushing tests down; doubleKind, the taxonomy; shouldMock, when a mock is right; contextStartups, the cache arithmetic; and survivesRefactor, which assertions are durable. A stub scores zero, so a passing run means you encoded the rules rather than guessed.
Click Run to compile and execute the code.
Summary
- Push each test down to the cheapest level that can still falsify the concern — not to a quota of unit tests.
- What a failure tells you matters as much as what it costs to run. A slow test that localises badly costs twice.
- JUnit creates a fresh instance per test method, which is why order-dependent failures are designed out.
- A mock is a proxy that records calls and replays stubs. That is the entire mechanism.
- An unstubbed method returns a default, so a forgotten stub appears as an
NullPointerExceptioninside your code. - A mock encodes your belief about a collaborator — which is the one thing a test containing it cannot verify.
- Verifying call sequences makes tests fail on harmless refactors, carrying no information when they do.
- Mock for side effects, slowness and error paths; not for pure functions, value objects, or the database.
- Spring caches one context per distinct configuration. Varying
@MockitoBeanper class turns seconds into minutes. - A slice is fast because it is narrow, and the narrowness is exactly what it cannot test.
Tests tell you the code is right. The next chapter is about whether the thing you ship is the thing you tested: Maven and Gradle, dependency resolution, and builds that produce the same artefact twice.
The deepest problem with mocks is epistemic rather than technical, and the forty-line implementation makes it plain: a mock returns what you told it to return. Every assertion downstream of it is therefore conditional on a belief you hold about a system you did not consult, and the test cannot distinguish a correct belief from a confident one. That is fine — a flight simulator is also wrong about everything and still worth having — but it means a suite of mocked tests measures internal consistency, and internal consistency is exactly what a team that misunderstood an API will have in abundance. I have come to read a fully-mocked test suite the way I would read a very detailed plan that nobody has tried: impressive, coherent, and silent about the one question that matters. The cheapest correction is usually a single test per boundary that touches the real thing, which is not a lot of tests and is the difference between evidence and agreement.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.