Unit Testing: JUnit 5 and Mockito
"My code runs" and "my code still runs after somebody else edits it" are two different claims. A test is a specification that executes itself: it writes "given these inputs, this result must follow" as code, and every time anyone changes anything it re-runs and tells you within seconds which promise got broken. A unit test is the cheapest and fastest tier of that — it exercises one class, swapping its database, its HTTP clients and every other collaborator for controllable fakes, so it reaches a verdict in milliseconds. This article covers three things: how a test class is organised (lifecycle and naming), how to conjure the scenario you want out of fake objects (stubbing and verification), and when not to use fakes at all (the boundary of mocking).
Five words, one line each (used throughout):
- Unit test: logic of a single class, every external dependency replaced by a controllable double, running in milliseconds
- Mock (a simulated object): a fake object that follows a script. Tell it "when asked for id=1, answer alice" and it obeys — and it also remembers how many times it was asked
- Assertion: a hard check phrased as "I believe the result should be X". If it fails, the test turns red and prints what actually happened
- Test double: the umbrella name for every stand-in object, covering the five roles Dummy / Stub / Spy / Mock / Fake
- Context: Spring's big machine holding all your beans. Booting it costs seconds, which is exactly why a pure unit test refuses to touch it
a Mock is the stunt double in an action film. The lead (your business logic) still plays every scene that is really theirs, but the dangerous, expensive, slow or plain absurd actions — jumping off a second floor, driving a real Ferrari, talking to a real bank — go to the double. The director says "fall down three times here" and the double falls three times; after filming you can audit "how many shots used the double" (that is verify). The upside is obvious: no road closures, no insurance, twenty takes before lunch. The downside is just as brutal — if the double moves differently from the real actor, the film passes rehearsal and dies on location, which is precisely "all unit tests green, production exploding on a SQL statement".

That timeline is the skeleton of Section 3: once at each end (@BeforeAll / @AfterAll), and in between a fresh Before → Test → After round per case. Once you see it, you understand why "cases may not depend on each other" — because at the start of every case the world has just been rebuilt.
After this article you should be able to answer three questions:
- To verify "shipping is free above 100", do I need to start Spring? Why or why not?
- How do I make a dependency throw in order to test the failure branch, and then confirm it was genuinely called once?
- When a test goes red with
Wanted but not invoked ... zero interactions, what is that sentence actually telling me?
Let us admit a reality first: many people hate writing tests because they treat them as extra work rather than a design tool. When a method is hard to unit-test, it usually means it is too coupled and its responsibilities are unclear — the first value of a test is that it forces you to write code that is easier to assemble.
What does a "unit test" actually test? One class or one method, with every external dependency replaced by a controllable double. That draws the boundary between three kinds of tests:
| Level | Scope | Speed | Maintenance | Typical tools |
|---|---|---|---|---|
| Unit test | A single class / method, all external deps isolated | Milliseconds | Low | JUnit 5 + Mockito |
| Integration test | Several components + container / DB | Seconds to tens of seconds | Medium | @SpringBootTest + Testcontainers |
| End-to-end test | The whole user journey | Tens of seconds to minutes | High | Playwright / RestAssured |

in a Spring Boot project, the biggest discipline for unit tests is to leave the container. Starting a Spring context costs seconds, and if every test depends on it, a few hundred cases take minutes — once feedback is slow, people stop running tests and the tests are dead.
The pyramid is static, but "why does this layer own that question" has to be asked one level at a time. The diagram below stacks the four layers; click a layer to see its job. Pay particular attention to layer ② (slices) — the layer teams most often skip entirely, and the one they most regret skipping:
A unit test should cover business branches: every path of an if, the boundary values, the exception paths. It should not verify "can Spring wire the beans" (that is integration testing) or the framework itself.
So where exactly do the two tiers differ? The animation below walks one and the same case down the unit road first and the integration road second, frame by frame, so you can compare their cost against what each actually proves — pay attention to frame four, what the unit road cannot see, which is the image version of the last sentence above:

the unit road is shooting in the studio: green screen, stunt doubles, no permits filed anywhere, a retake costs thirty seconds — so you can afford to shoot every branch ten times. The integration road is shooting on location: the car is real, the bank counter is real, and you must wrap before sunset, so you get three setups a day — you spend them only on the shots that matter (the order-placement flow, the payment callback). Neither is "the right way"; the question is always which cost this particular promise deserves.
No need to add dependencies one by one — Spring Boot packs the whole testing toolkit into one starter:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope></dependency>It pulls in these everyday libraries at once:
| Library | Purpose | What you will use |
|---|---|---|
| JUnit 5 (Jupiter) | Test engine and annotations | @Test / @BeforeEach / @ParameterizedTest |
| Mockito | Stubbing and verification | @Mock / when() / verify() |
| AssertJ | Fluent assertions | assertThat(...).isEqualTo(...) |
| Hamcrest | Matchers (common in legacy code) | assertThat(x, is(y)) |
| JSONassert / JsonPath | JSON assertions (for web tests) | jsonPath("$.id") |
| Spring Test | Integration-test support | @SpringBootTest / MockMvc |
One starter bundles the whole toolkit, but each project still adds two or three more: an in-memory database for persistence tests, Testcontainers for the real one, Lombok scope for test code only. Tick the boxes and watch the <scope> on each line — the "whole class finishes in under one second" requirement in Tier 3 of Section 15 starts right here:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Directory convention: sources live in src/main/java, tests in src/test/java, with a one-to-one package and class structure; the class is usually SubjectUnderTest + Test:
src/test/java/com/example/demo/service/UserServiceTest.javaTip: Maven only treats classes under src/test/java as tests, and test dependencies use the test scope — they never enter the production jar. Do not put tests in src/main, or they will ship in the artifact.
JUnit 5 manages the "lifecycle" of a test class with annotations. A standard skeleton looks like this:
class OrderServiceTest { private OrderService orderService; private OrderRepository repository; @BeforeAll // once for the whole class: for expensive setup static void initAll() { System.out.println("== class-level setup =="); } @BeforeEach // before each test: reset the subject to a clean state void setUp() { repository = mock(OrderRepository.class); orderService = new OrderService(repository); } @AfterEach // after each test: clean up transient state void tearDown() { // a unit test usually needs nothing here; this shows the structure } @Test void should_return_order_when_id_exists() { // Given (arrange) Order order = new Order(1L, new BigDecimal("99.00")); when(repository.findById(1L)).thenReturn(Optional.of(order)); // When (act) Order result = orderService.getById(1L); // Then (assert) assertThat(result.getAmount()).isEqualByComparingTo("99.00"); }}The three lifecycle annotations divide the work: @BeforeEach wipes the arena before each case, @BeforeAll does expensive setup once per class (it must be static), and @AfterEach handles teardown.
The Given-When-Then three-part structure is what makes a test readable: arrange data, act, assert — and never interleave the parts. For naming, should_xxx_when_yyy lets a failure tell you where it broke just from the method name:
| Name | Verdict |
|---|---|
test1 | No information; you must read the code after a failure |
testGetById | Says "what", not "what is verified" |
should_return_order_when_id_exists | Behaviour plus condition, readable in one line |
tests must not depend on each other. JUnit does not guarantee method order, so if case B relies on side effects from case A, running B alone fails mysteriously. Every case must be self-sufficient and runnable in isolation.
Section 10 turns this ordering into an interactive lab (including the full timeline from @BeforeAll to a failed assertion); for now remember one line: once at each end, one round per case in between.
Reading the picture is not enough — "@BeforeEach, how many times is that again?" is precisely what people memorise backwards. So spread it out as a single-step run: the eight lines of this test class on the left, the live instance and state on the right. Press step repeatedly and watch box ⑦ — the mocks are brand new again and every counter is zero — because that box is the physical explanation of "cases may not depend on each other":
@BeforeAll static void initAll() // ① once per class, and it must be static@BeforeEach void setUp() // ② before every case: rebuild mocks and subject@Test void should_return_order() { // ③ case 1 begins when(repository.findById(1L)).thenReturn(Optional.of(order)); // ④ stub assertThat(result.getAmount()).isEqualByComparingTo("99.00"); // ⑤ assert@AfterEach void tearDown() // ⑥ case 1 ends: reset the arena@BeforeEach void setUp() (case 2) // ⑦ before case 2: everything is new again@AfterAll static void closeAll() // ⑧ the single closing hook| runs | once per class |
| test instance | does not exist yet |
| why static | no instance exists to call a method on |
JUnit Jupiter EngineClassPredicateJUnit 5's built-in assertions are enough, but AssertJ's fluent style reads like natural language and is harder to get wrong:
// Native JUnit 5 assertionsassertEquals(5, list.size());assertThrows(IllegalArgumentException.class, () -> service.withdraw(-1));assertAll("order checks", () -> assertEquals("A001", order.getNo()), () -> assertEquals(Status.CREATED, order.getStatus()));assertTimeout(Duration.ofMillis(200), () -> service.quickTask());// AssertJ fluent assertions: chained, with clearer failure messagesList<User> users = userService.findActive();assertThat(users) .hasSize(3) .extracting(User::getUsername) .containsExactlyInAnyOrder("alice", "bob", "carol");assertThatThrownBy(() -> service.withdraw(-1)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("amount");assertThat(order.getAmount()).isEqualByComparingTo("99.00"); // use this for BigDecimal, not equals| Need | JUnit 5 | AssertJ |
|---|---|---|
| Equality | assertEquals(a, b) | assertThat(a).isEqualTo(b) |
| Exception | assertThrows(Ex.class, ...) | assertThatThrownBy(...).isInstanceOf(...) |
| Collection content | A hand-written loop | .extracting(...).containsExactly(...) |
| Aggregated assertions | assertAll(...) | One chain, many assertions |
| BigDecimal | Needs a delta | .isEqualByComparingTo() is clearer |
never compare BigDecimal with equals — new BigDecimal("1.0") and new BigDecimal("1.00") return false from equals (different scales). Always assert amounts with isEqualByComparingTo or compareTo.
Mockito answers "what about external dependencies": replace a real dependency with a fake one and dictate what it answers when asked. Here is a complete, runnable service-layer test class:
@ExtendWith(MockitoExtension.class) // without it, @Mock does nothing (trap number one)class UserServiceTest { @Mock private UserRepository userRepository; // a fake repository, no database involved @InjectMocks private UserService userService; // the subject; Mockito injects the @Mock fields above @Test void should_return_user_when_found() { // Given: make findById(1) return a user User user = new User(1L, "alice"); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); // When User result = userService.getById(1L); // Then assertThat(result.getUsername()).isEqualTo("alice"); verify(userRepository, times(1)).findById(1L); // it really was called exactly once } @Test void should_throw_when_not_found() { when(userRepository.findById(9L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> userService.getById(9L)) .isInstanceOf(NoSuchElementException.class); } @Test void should_pass_saved_user_to_repository() { // ArgumentCaptor: capture the argument handed to the repository for inspection ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class); userService.register("bob", "bob@example.com"); verify(userRepository).save(captor.capture()); assertThat(captor.getValue().getUsername()).isEqualTo("bob"); assertThat(captor.getValue().getEmail()).isEqualTo("bob@example.com"); }}
The everyday weapons in one table:
| Syntax | Purpose |
|---|---|
when(x).thenReturn(v) | Define "return v when called" |
when(x).thenThrow(ex) | Define "throw when called", to test error branches |
doNothing().when(mock).voidCall() | void methods do nothing by default; use this to stub side effects |
verify(mock, times(n)) | Verify it was called n times |
verify(mock, never()) | Verify it was never called |
ArgumentCaptor<T> | Capture the actual argument for deep checks |
@Mock not working is nine times out of ten a missing @ExtendWith(MockitoExtension.class). Without that extension, Mockito never scans and injects the annotations, the field stays null, and you get an NPE on call. Another common cause is using @MockBean (that one is Spring's, for integration tests) — do not mix them up.
Overusing mocks degrades a test into "verifying that my code called my code" — it merely restates what you wrote and verifies nothing. Draw two lines:
- Mock it: external I/O (database, HTTP, message queues), slow resources, and non-deterministic dependencies (current time, random numbers, UUIDs)
- Do not mock: simple value objects, pure utility functions, your own domain entities — constructing them directly is more realistic than setting up a mock
// BAD: over-mocking even a value object makes the test brittle and meaninglessOrder mockOrder = mock(Order.class);when(mockOrder.getAmount()).thenReturn(new BigDecimal("10"));// GOOD: construct value objects directly; only the real boundary (a repository) is mockedOrder order = new Order(1L, new BigDecimal("10"));when(repository.findById(1L)).thenReturn(Optional.of(order));Note: the rule of thumb is plain — "if this thing breaks, is it what I want to locate in this unit test?" If it is the framework or an external system, mock it; if it is part of your own business logic, let it really run.
Draw that rule as two columns and the line becomes obvious. Everything on the left is something you do not want to be debugging inside a unit test; everything on the right, once mocked, turns the test into a restatement of your own code:

When the same logic must be checked with many inputs, do not copy ten @Tests — use @ParameterizedTest:
@ParameterizedTest@ValueSource(ints = {0, -1, -100})void should_reject_non_positive_amount(int amount) { assertThatThrownBy(() -> service.withdraw(amount)) .isInstanceOf(IllegalArgumentException.class);}@ParameterizedTest@CsvSource({ "1, alice, 10.0", "2, bob, 0.0", "3, carol, -5.5"})void should_parse_order_line(long id, String name, BigDecimal amount) { Order order = parser.parse(id + "," + name + "," + amount); assertThat(order.getId()).isEqualTo(id);}@ParameterizedTest@MethodSource("provideBoundaryCases")void should_classify_boundary(Order order, Level expected) { assertThat(classifier.classify(order)).isEqualTo(expected);}static Stream<Arguments> provideBoundaryCases() { return Stream.of( Arguments.of(new Order(1L, new BigDecimal("0")), Level.LOW), Arguments.of(new Order(2L, new BigDecimal("100")), Level.MID), Arguments.of(new Order(3L, new BigDecimal("9999")), Level.HIGH) );}| Data source | Best for |
|---|---|
@ValueSource | Simple single-type values (int / String) |
@CsvSource | Multiple parameters writable on one line |
@MethodSource | Complex objects needing construction logic |
@EnumSource | Iterating every value of an enum |
parameterized test names default to an index (e.g. [2] -5), which is hard to locate when something fails. Adding @ParameterizedTest(name = "case {index}: {0} should be rejected") makes the report speak plainly.
"Test double" is an umbrella term; the five doubles have different jobs and are a favourite interview question:
| Double | In a sentence | Behaviour |
|---|---|---|
| Dummy | A placeholder, never actually used | Only fills an argument list |
| Stub | Returns a preset value when called | Only cares "what it answers" |
| Spy | Wraps a real object, optionally stubbing parts | Real logic by default, partial override |
| Mock | Records calls for later verification | Cares "was it called as agreed" |
| Fake | A simplified implementation with real behaviour | e.g. an in-memory repository |
How @Spy is actually used — to keep real logic while replacing one method:
@Spyprivate AuditService auditService = new AuditService(); // real methods by default@Testvoid should_skip_real_write_but_count_calls() { doNothing().when(auditService).flush(); // stub only flush auditService.record("login"); // the real logic runs verify(auditService).flush(); // but flush was replaced}Attention: @Spy is tempting, but it easily hides a design problem — if some code needs a spy to be testable, the logic often should be extracted and tested on its own. Treat spying as the exception, not the norm.
One Maven plugin generates a coverage report:
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <executions> <execution> <id>prepare-agent</id> <goals><goal>prepare-agent</goal></goals> </execution> <execution> <id>report</id> <phase>verify</phase> <goals><goal>report</goal></goals> </execution> </executions></plugin>After mvn verify the report lands in target/site/jacoco/index.html.
coverage is a health metric, not a performance metric. Chasing a number with tests that "call a method and assert nothing" can reach 100% while verifying nothing — it barely proves even the minimum, that a method ran. What actually matters is branch coverage (were both sides of each if exercised) and whether your most important business logic is covered, not the percentage.
Still, "no gate at all" and "gate at 100%" are failures with the same name and opposite costs. Drag this line and watch what kind of test code each end pressures people into writing:
- Sixty to seventy percent means the main branches are covered; the rest is getters and config classes
- Beyond that the return falls below its cost: are you testing the framework or the business?
- Track branch coverage alongside it — seventy percent line coverage can still mean no else was ever entered
- Pair it with a review rule: every case must contain at least one assertion
Everything so far has been paper rules. The six labs below answer: in what order does a test class really run, how does a fake get into the field, when must the real container show up, how to read that red line when an assertion fails, which slice of the request a slice test actually guards, and why Failed to load ApplicationContext has no business appearing in a unit test.
Before entering the lab, play one round on the annotations themselves. Testing annotations are not like @Service where writing it makes it so — each one does a different job, and mis-picking one is worth a whole pitfall:
The first is the main instrument; its four positions map onto Mockito's four typical moves — run shows the execution order (matching the skeleton in Section 3 exactly), mock shows the moment a dependency gets replaced, verify shows the interaction counter, and fail hands you a genuine failure message to copy into your notes:
The second answers the most common beginner puzzle: why does a field annotated @Mock have a value at all? Behind it sits one wiring step, governed by the same timing model as injection in Article 6:
The third confirms the discipline stated in Section 1: the moment what you want to prove is "can Spring wire this" or "did the annotation really apply", you have left unit-test territory. Pick "@SpringBootTest boot" to see everything extra it does, then "Context caching" to see why the second run is much faster:
The fourth returns to the starting point: once the container manages your subject, its construction, initialization and destruction are no longer yours to control — which is exactly why unit tests avoid the container:
The fifth fills the slice-test gap: which segment does @WebMvcTest actually guard? On /users/42 watch a path variable become a method parameter, then try /nope and see which layer answers the 404 — these are exactly the things a unit test can never prove and a full container is too expensive to prove:
The sixth targets the second-to-last row of Section 13, Failed to load ApplicationContext. In a unit test it means one thing: this class should never have booted a container. Switch to "Reading a failed start" to learn the shape of that wall of text, then check whether your test class carries @SpringBootTest:
That line in the third lab — "the second run is much faster" — is context caching. The animation spreads its six nodes out; watch frame ⑤: one extra @MockitoBean changes the cache key, so a second container gets booted. Nine times out of ten, "our suite keeps getting slower" is not more code but this box running away:

Reuse is a genuine numeric knob, and its cost hides in CI's wall clock. Drag it and you will see why "keep integration test configurations identical" is advice about time, not tidiness:
- Four threads is roughly half the cores of a typical CI box
- You also need @Execution(CONCURRENT) or the global mode=concurrent
- Pure unit tests gain almost everything and risk almost nothing — they share no state
- This band usually cuts a multi-minute test phase below one minute
Once you are bored of buttons, type the commands yourself. This console talks to the same in-browser Java kernel and every reply is computed there — start with beans to see what the container holds, then walk one test case from stub to verification to failure:
run lab mock verify and then lab mock fail back to back — the first prints what the mock is actually counting, the second gives you one real red line. Read them against the table in Section 13 and you notice every failure message says only two things: what was expected, and how many times reality happened.
Get "should I boot a container?" wrong and you pay in one of two opposite directions: under-unitised means ten minutes per run and nobody runs it; over-integrated means all green yet not one business branch tested, so the next change still breaks production. This sandbox turns "which promise do you want to prove" into one switch; each position shows the choice, the latency and what actually gets proven:
choice: plain JUnit 5 + Mockito (no @SpringBootTest anywhere)latency: 2-5ms per case; 1200 cases across a module in ~8sRepository / HTTP clients all become @Mock# test: the verdict depends only on your own if-statements and arithmetic -> unitblind spot: a typo in SQL or a transaction that never applied can never be caught here
the four positions are ordered by cost. Ask first "what happens if this promise breaks", then "what is the smallest tool that can prove it" — never the reverse, deciding on a container before knowing what you are proving. The first question in Section 12 tests exactly that chain of reasoning.
A warm-up on the message you will see with your own eyes in the fail position of Section 10:
Now a combined question threading Sections 2, 5 and 6 together:
A red test is not scary; not being able to read those red lines is. Every fragment below can be pasted verbatim into a search engine, and the right-hand columns translate them into plain language.
| Error text (fragment) | Real cause | 30-second fix | Where to dig deeper |
|---|---|---|---|
org.mockito.exceptions.misusing.UnnecessaryStubbingException: Unnecessary stubbings detected. Clean & maintainable test code requires zero unnecessary code. | Under strict stubs, some when(...) was never used — typically you changed the business code afterwards, or the stub belongs to another case | Delete the redundant when; if lenient behaviour is genuinely needed, annotate the class with @MockitoSettings(strictness = Strictness.LENIENT) after understanding why it went unused | Section 5 |
Wanted but not invoked ... However, there were exactly 0 interactions with this mock | The call you expected never happened: the branch was not reached, the arguments did not match, or the dependency was not injected as a mock | Check for null first (an NPE would come sooner); then verify the matchers equal the real arguments; finally confirm the method under test calls it at all | Section 5 |
Wanted but not invoked ... there were exactly 2 interactions with this mock | Expectation versus reality mismatch (the code calls twice, or you wrote times(1) wrongly) | If two legitimate calls exist, change to times(2); if not, you just found a real bug — go look at the loop | Section 12 |
Argument(s) are different! Wanted: repo.save(<Order@1a2b>) Actual invocations have different arguments: repo.save(<Order@3c4d>) | Arguments are compared with equals() by default, and your entity does not override equals, so identical data still counts as different | Add equals/hashCode to the value object (or make it a record); alternatively assert field by field with argThat(...) or ArgumentCaptor | Section 5 |
java.lang.NullPointerException: Cannot invoke "OrderRepository.findById(...)" because "this.repository" is null | @Mock / @InjectMocks never took effect — nine times out of ten the extension annotation is missing | Add @ExtendWith(MockitoExtension.class); or call MockitoAnnotations.openMocks(this) inside @BeforeEach | Section 5 |
Missing parameter values for the following placeholders: {0} | The {0} in @ParameterizedTest(name = "...") refers to a parameter that does not exist, or @CsvSource column count does not match the signature | Reconcile placeholder indices with parameter order: {index} is the case number while {0} is the first argument | Section 7 |
No tests found for given includes, or Maven running zero tests | The class name breaks the Surefire convention (it must end in Test / Tests / TestCase), or the test lives under src/main/java | Rename or move it; run it once from the IDE to confirm it is discovered | Article 34 |
Failed to load ApplicationContext | Looks like a unit test failing, but the class actually carries @SpringBootTest, so a wiring problem takes down a whole batch | A pure unit test should never produce this: move container-backed cases into the integration package and run them under a separate profile | Article 34 |
Static-method or constructor mocking: a pile of MockedStatic boilerplate, or reaching for PowerMock | Not a framework trap but a design signal — you depend on a collaborator that cannot be replaced | Prefer refactoring: pass time, randomness and id generation in as parameters or an injected Clock / IdGenerator; if you truly must, use Mockito's mockStatic inside try-with-resources so it closes | Section 6 |
reading a failure needs only two fragments — the exception class before the colon tells you who is complaining (misusing is a stubbing-shape problem, comparison an assertion problem, wanted a verification problem) and the first sentence after the colon tells you what differs. Do not start parsing the stack trace from the third line.
The second-to-last row, Failed to load ApplicationContext, deserves a dedicated rehearsal: it prints a full screen, takes down a whole batch of cases at once, and the real cause is usually that the class should never have booted a container. Do not read the answer — click the frame you think is guilty:
One new case was added to OrderService. Every other class still passes; this one goes red — and fourteen cases in the same class fail together.
@Mock injection fails, usually because @ExtendWith(MockitoExtension.class) is missing; and if you used @MockBean, that belongs to integration testing and starts a Spring context, so keep it out of pure unit tests.
tests depend on each other. JUnit guarantees no order, so any "B only passes after A" test is a time bomb; use @BeforeEach so every case gets a clean state.
over-coupling to exception messages. An assertion like hasMessage("user[1] not found") hard-codes the wording, and changing the message turns it red. Use loose matches such as hasMessageContaining("not found"), so the test watches behaviour, not phrasing.
Create src/test/java/com/example/demo/service/FreightRuleTest.java and pin the rule "free shipping above 100" so that a careless edit cannot slip past. Fully runnable code with expected output:
package com.example.demo.service;import java.math.BigDecimal;public class Order { private final long id; private final BigDecimal amount; public Order(long id, String amount) { this.id = id; this.amount = new BigDecimal(amount); } public long getId() { return id; } public BigDecimal getAmount() { return amount; }}package com.example.demo.service;public interface ShippingClient { BigDecimal quote(long orderId); // in real life this calls an external carrier, so it must be mocked}package com.example.demo.service;import java.math.BigDecimal;public class OrderService { private static final BigDecimal FREE_THRESHOLD = new BigDecimal("100"); private final ShippingClient shippingClient; public OrderService(ShippingClient shippingClient) { this.shippingClient = shippingClient; } /** Free shipping at or above 100: return zero and do not even ask for a quote */ public BigDecimal freight(Order order) { if (order.getAmount().compareTo(FREE_THRESHOLD) >= 0) { return BigDecimal.ZERO; } return shippingClient.quote(order.getId()); }}package com.example.demo.service;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;import org.mockito.InjectMocks;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;import java.math.BigDecimal;import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.ArgumentMatchers.anyLong;import static org.mockito.Mockito.never;import static org.mockito.Mockito.verify;import static org.mockito.Mockito.when;@ExtendWith(MockitoExtension.class)class FreightRuleTest { @Mock private ShippingClient shippingClient; // the external carrier: impersonate it @InjectMocks private OrderService orderService; // Mockito injects the mock above @ParameterizedTest(name = "amount {0} should pay freight {1}") @CsvSource({"99.99, 8.00", "100.00, 0", "100.01, 0"}) void should_charge_freight_below_threshold_only(String amount, String expected) { when(shippingClient.quote(1L)).thenReturn(new BigDecimal("8.00")); BigDecimal freight = orderService.freight(new Order(1L, amount)); assertThat(freight).isEqualByComparingTo(expected); } @Test void should_not_call_shipping_client_when_amount_reaches_threshold() { BigDecimal freight = orderService.freight(new Order(7L, "100")); assertThat(freight).isEqualByComparingTo("0"); verify(shippingClient, never()).quote(anyLong()); // the real point: it was never asked }}Expected output (mvn test, or run the class from your IDE):
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0FreightRuleTest ✔ amount 99.99 should pay freight 8.00 ✔ amount 100.00 should pay freight 0 ✔ amount 100.01 should pay freight 0 ✔ should_not_call_shipping_client_when_amount_reaches_thresholdTwo things to notice: ① three parameterized cases share one method body, so the boundaries are visible at a glance; ② the verify(never()) in the second case is the real defence — if someone rewrites the logic as "quote first, then decide the discount", the returned value stays correct but external calls double, and this test goes red immediately.
Goal: manufacture the friendliest error in Section 13 and read it yourself.
Hint: add one line that will never be used before should_not_call_shipping_client_when_amount_reaches_threshold: when(shippingClient.quote(99L)).thenReturn(new BigDecimal("12.00"));, then rerun the whole class.
You should observe: both cases turn red with UnnecessaryStubbingException, and the message names the exact method and line. Two ways out — delete the redundant stub (correct), or annotate the class with @MockitoSettings(strictness = Strictness.LENIENT) (painkiller). Try both, then explain in your own words why Mockito is strict by default: an unused stub makes the next reader believe that dependency matters here, and over time nobody dares to delete anything.
Write the tests for a small real service: CouponService.applyDiscount(Order order, Coupon coupon) where a fixed-amount coupon needs its threshold, a percentage coupon is capped at 50, and an expired coupon is rejected outright.
Acceptance checklist:
- [ ] At least 8 cases, half of them parameterized, covering "exactly at the threshold", "one cent below", "coupon expired" and "percentage would exceed 50"
- [ ]
Couponis a record or overridesequals/hashCode, soverify(couponRepository).findById(3L)passes instead of failing withArgument(s) are different - [ ] Time comes from an injected
Clockpinned withClock.fixed(...)in tests;LocalDateTime.now()appears nowhere - [ ] No case carries
@SpringBootTest, and the whole class finishes in under one second - [ ] Changing the cap from 50 to 60 turns at least two cases red (proof they actually guard something)
- [ ] For every case you can point at its Given, When and Then, with the three phases never interleaved
from memory, draw the order in Section 3: @BeforeAll → (@BeforeEach → @Test → @AfterEach) × N → @AfterAll, and say why @BeforeAll must be static.
what question does when(...) answer, and what question does verify(...) answer? The answers must be "what is returned" and "was it called like this" — they are not interchangeable.
for UnnecessaryStubbingException, Wanted but not invoked ... 0 interactions and Argument(s) are different, name the disease behind each. If the third escapes you, reread the paragraph about equals.
what deserves a mock and what does not? The standard answer is a single sentence — if this breaks, am I the one locating it inside a unit test?
what separates @Mock from @MockitoBean? The first belongs to pure unit tests (milliseconds, no container); the second is Spring's and boots a context, putting it in integration territory.
JUnit owns the order, Mockito owns the doubles; stubbing answers "what comes back", verify asks "did it happen"; construct value objects directly, parameterize the boundaries; when it is red read the class name then the first sentence — and even when it is green, do not forget one verify(never()).
Compress this article into a few lines — the value of a unit test is that it forces low-coupling, assemblable code and gives you millisecond feedback; JUnit 5 owns the lifecycle (@BeforeEach/@BeforeAll) and assertions, Mockito owns stubbing (when) and verification (verify/ArgumentCaptor); Given-When-Then plus should_xxx_when_yyy naming makes tests self-explanatory; mock only at real boundaries, and construct value objects and pure functions directly; parameterized tests kill duplicated cases, and @Spy is the exception rather than the norm; coverage is a health metric, not a performance target. Master these and your tests become an asset, not a burden.