Unit Testing: JUnit 5 and Mockito

bee2026-10-0856 min read0 views
Why tests feel like a burden, then JUnit 5 assertions, @Mock/@InjectMocks stubbing, parameterized tests and naming — with a runnable service-layer test suite.
1 / 145
Section
0. The 30-second version
2 / 145

"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).

3 / 145

Five words, one line each (used throughout):

4 / 145
  • 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
5 / 145
类比

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".

6 / 145
Diagram
Figure · The real execution order of one test class
Figure · The real execution order of one test class
7 / 145

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.

8 / 145

After this article you should be able to answer three questions:

9 / 145
  • 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?
10 / 145
Section
1. The value and the boundary of unit tests
11 / 145

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.

12 / 145

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:

13 / 145
Table
LevelScopeSpeedMaintenanceTypical tools
Unit testA single class / method, all external deps isolatedMillisecondsLowJUnit 5 + Mockito
Integration testSeveral components + container / DBSeconds to tens of secondsMedium@SpringBootTest + Testcontainers
End-to-end testThe whole user journeyTens of seconds to minutesHighPlaywright / RestAssured
14 / 145
Diagram
Figure 1 · The testing pyramid
Figure 1 · The testing pyramid
15 / 145
Note

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.

16 / 145

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:

17 / 145
Diagram
LayersFour layers stacked: each answers exactly one question1 / 4
Go ①→④. Layer ② is the interesting one — it is the answer for 'I neither want to boot a container nor give up verifying the web contract'
→
→
→
① Unit test: is this rule computed right?
Scope is **one class**, every dependency replaced by @Mock. Milliseconds, you can afford thousands of them, no environment needed. Its blind spot is equally clear: a typo in SQL, a transaction that never applied, a bean that never loaded — a unit test can never catch these, because there is no proxy in the room.
All clearTop to bottom: lower layers are faster, cheaper and should be written in greater numbers; upper layers are slower, costlier and should be a handful. That slope is the whole pyramid.
18 / 145

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.

19 / 145

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:

20 / 145
Animation
Animation · One test case down two roads: unit versus integration
Animation · One test case down two roads: unit versus integration
21 / 145
类比

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.

22 / 145
Section
2. Getting started with JUnit 5: what is inside spring-boot-starter-test
23 / 145

No need to add dependencies one by one — Spring Boot packs the whole testing toolkit into one starter:

24 / 145
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-test</artifactId>    <scope>test</scope></dependency>
25 / 145

It pulls in these everyday libraries at once:

26 / 145
Table
LibraryPurposeWhat you will use
JUnit 5 (Jupiter)Test engine and annotations@Test / @BeforeEach / @ParameterizedTest
MockitoStubbing and verification@Mock / when() / verify()
AssertJFluent assertionsassertThat(...).isEqualTo(...)
HamcrestMatchers (common in legacy code)assertThat(x, is(y))
JSONassert / JsonPathJSON assertions (for web tests)jsonPath("$.id")
Spring TestIntegration-test support@SpringBootTest / MockMvc
27 / 145

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:

28 / 145
Generator
GeneratorWhich dependency lines a testing project needspom.xml4 / 9
Tick Test alone first for the minimal spring-boot-starter-test, then add JPA + H2 and see why an in-memory database belongs to test scope; the MySQL line's scope is the opposite of H2's — compare with article 34's integration-test setup
Output
<?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>
Why each choice matters
parentInheriting 3.3.4 starter-parent means no spring-boot-starter-* needs a version; the moment someone adds an explicit version to one starter, that one wins — the most common source of dependency drift.
WebAnything that serves HTTP needs it: DispatcherServlet, embedded Tomcat and JSON mapping come inside this starter.
Data JPABrings spring-orm plus Hibernate: write a Repository interface and an implementation appears.
H2 内存库Runtime scope so local runs and tests need no real server; exclude it from the prod profile.
Testscope=test: @SpringBootTest, MockMvc and AssertJ live here; without it @Test is unresolved.
29 / 145

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:

30 / 145
Code
Codetext
src/test/java/com/example/demo/service/UserServiceTest.java
Notes

Tip: 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.

31 / 145
Section
3. Test lifecycle and structure
32 / 145

JUnit 5 manages the "lifecycle" of a test class with annotations. A standard skeleton looks like this:

33 / 145
java
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");    }}
34 / 145

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.

35 / 145

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:

36 / 145
Table
NameVerdict
test1No information; you must read the code after a failure
testGetByIdSays "what", not "what is verified"
should_return_order_when_id_existsBehaviour plus condition, readable in one line
37 / 145
Trap

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.

38 / 145

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.

39 / 145

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":

40 / 145
Stepper
StepperStep through one test class: once at each end, one round per case1 / 8
Walk ①→⑧. In box ⑦ the subject and the mocks are freshly rebuilt for the second case
Code under debug
1@BeforeAll static void initAll() // ① once per class, and it must be static
2@BeforeEach void setUp() // ② before every case: rebuild mocks and subject
3@Test void should_return_order() { // ③ case 1 begins
4 when(repository.findById(1L)).thenReturn(Optional.of(order)); // ④ stub
5 assertThat(result.getAmount()).isEqualByComparingTo("99.00"); // ⑤ assert
6@AfterEach void tearDown() // ⑥ case 1 ends: reset the arena
7@BeforeEach void setUp() (case 2) // ⑦ before case 2: everything is new again
8@AfterAll static void closeAll() // ⑧ the single closing hook
Variables now
runsonce per class
test instancedoes not exist yet
why staticno instance exists to call a method on
Call stack
1JUnit Jupiter Engine
2ClassPredicate
1@BeforeAll runs before a test-class instance is created — and since every case gets its own instance, a once-per-class hook can only hang on the class itself, which Java spells static (unless you switch to @TestInstance(PER_CLASS)). Expensive resources — connections, temp directories — belong here, once only.
41 / 145
Section
4. The assertion system: from assertEquals to AssertJ
42 / 145

JUnit 5's built-in assertions are enough, but AssertJ's fluent style reads like natural language and is harder to get wrong:

43 / 145
java
// 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());
44 / 145
java
// 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
45 / 145
Table
NeedJUnit 5AssertJ
EqualityassertEquals(a, b)assertThat(a).isEqualTo(b)
ExceptionassertThrows(Ex.class, ...)assertThatThrownBy(...).isInstanceOf(...)
Collection contentA hand-written loop.extracting(...).containsExactly(...)
Aggregated assertionsassertAll(...)One chain, many assertions
BigDecimalNeeds a delta.isEqualByComparingTo() is clearer
46 / 145
Key point

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.

47 / 145
Section
5. Mockito's core weapons: stubbing and verification
48 / 145

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:

49 / 145
java
@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");    }}
50 / 145
Animation
Animation · Stub and verify
Animation · Stub and verify
51 / 145

The everyday weapons in one table:

52 / 145
Table
SyntaxPurpose
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
53 / 145
Trap

@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.

54 / 145
Section
6. Where to draw the stubbing line
55 / 145

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:

56 / 145
  • 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
57 / 145
Code
Codejava
// 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));
Notes

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.

58 / 145

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:

59 / 145
Diagram
Figure · What to mock and what not to
Figure · What to mock and what not to
60 / 145
Section
7. Parameterized tests: one method, ten data sets
61 / 145

When the same logic must be checked with many inputs, do not copy ten @Tests — use @ParameterizedTest:

62 / 145
java
@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)    );}
63 / 145
Table
Data sourceBest for
@ValueSourceSimple single-type values (int / String)
@CsvSourceMultiple parameters writable on one line
@MethodSourceComplex objects needing construction logic
@EnumSourceIterating every value of an enum
64 / 145
Tip

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.

65 / 145
Section
8. Telling the doubles apart: Dummy / Stub / Spy / Mock / Fake
66 / 145

"Test double" is an umbrella term; the five doubles have different jobs and are a favourite interview question:

67 / 145
Table
DoubleIn a sentenceBehaviour
DummyA placeholder, never actually usedOnly fills an argument list
StubReturns a preset value when calledOnly cares "what it answers"
SpyWraps a real object, optionally stubbing partsReal logic by default, partial override
MockRecords calls for later verificationCares "was it called as agreed"
FakeA simplified implementation with real behavioure.g. an in-memory repository
68 / 145

How @Spy is actually used — to keep real logic while replacing one method:

69 / 145
Code
Codejava
@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}
Notes

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.

70 / 145
Section
9. Coverage: JaCoCo and "coverage is not the goal"
71 / 145

One Maven plugin generates a coverage report:

72 / 145
xml
<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>
73 / 145

After mvn verify the report lands in target/site/jacoco/index.html.

74 / 145
Trap

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.

75 / 145

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:

76 / 145
Tuner
TunerThe coverage gate: whatever you set it to is the kind of tests you get
jacoco.check.branch-covered-ratio
60% branches coveredNow 0 – 100
The common line for new projects
  • 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
Test quality80%
Regression safety70%
Set the gate where it still forces people to write assertions and can actually be held — a brake, not a score.
77 / 145
Section
10. Hands on: six labs that press through the whole chain
78 / 145

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.

79 / 145

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:

80 / 145
Match
MatchTesting annotation ↔ the job it actually doesMatched 0/7 · Missed 0
Seven hard mappings of syntax to responsibility. Watch pairs 2 and 5 — both change the context-cache key, which is what the animation in this section is about
Pick a card on the left first
81 / 145

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:

82 / 145
Kernel lab
TeaVMA test class's whole life: from @BeforeAll to a failed assertionidle
Align 'Execution order' with the timeline in Section 3, then 'Verify interactions' to see what verify actually counts, and finish on 'Reading an assertion failure'
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
83 / 145

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:

84 / 145
Kernel lab
TeaVMHow the subject receives its doublesidle
Cycle to setter and field, and compare against Section 5's note that @InjectMocks tries constructor injection first — the three styles decide at which step the double gets filled in
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
85 / 145

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:

86 / 145
Kernel lab
TeaVMWhen the real container must appearidle
Walk '@SpringBootTest boot' → 'Context caching' → '@WebMvcTest slice' and feel the sentence 'integration tests cost time because they boot a context'
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
87 / 145

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:

88 / 145
Kernel lab
TeaVMA subject's whole life: from construction to destructionidle
Switch to 'Prototype' and think about why unit tests avoid depending on the container lifecycle
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
89 / 145

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:

90 / 145
Kernel lab
TeaVMThe segment a slice test holdsidle
Path variable first, then the list, then the 404: walk all three and you know precisely what the 'contract' cell of the Section 11 sandbox is testing
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
91 / 145

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:

92 / 145
Kernel lab
TeaVMWhen a container sneaks into a unit testidle
This position shows the structure of a failed context start: read upward for the last caused by, and the first business-adjacent line is usually the bean that is missing configuration
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
93 / 145

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:

94 / 145
Animation
Animation · Context caching: who reuses and who rebuilds
Animation · Context caching: who reuses and who rebuilds
95 / 145

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:

96 / 145
Tuner
TunerRunning tests in parallel: raise the threads, and contexts and pools start fighting
junit.jupiter.execution.parallel.config.fixed.parallelism
4threadsNow 1 – 16
The usual setting: a clear win for pure unit tests
  • 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
CI wall clock42%
Context rebuilds20%
Ask first whether the slowness comes from the number of cases or from booting contexts over and over — only the former is solved by parallelism.
97 / 145

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:

98 / 145
Console
99 / 145
Note

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.

100 / 145
Section
11. Sandbox: which kind of test does this code deserve
101 / 145

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:

102 / 145
Sandbox
SandboxWhich test type: state what you want to prove first
Result
choice: plain JUnit 5 + Mockito (no @SpringBootTest anywhere)
latency: 2-5ms per case; 1200 cases across a module in ~8s
Repository / HTTP clients all become @Mock
# test: the verdict depends only on your own if-statements and arithmetic -> unit
blind spot: a typo in SQL or a transaction that never applied can never be caught here
Pricing, discount stacking, state transitions, validation rules: many branches, frequent edits. This is where unit tests pay off most.
103 / 145
Note

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.

104 / 145
Section
12. Checkpoints
105 / 145

A warm-up on the message you will see with your own eyes in the fail position of Section 10:

106 / 145
Quiz
Check yourselfA test contains `verify(paymentClient, times(1)).pay(any());` and fails with `Wanted but not invoked ... However, there were exactly 2 interactions with this mock`. What does that mean?
Pick one — you get feedback right away
107 / 145

Now a combined question threading Sections 2, 5 and 6 together:

108 / 145
Quiz
Check yourselfYou must unit-test the rule "free shipping above 100". OrderService depends on OrderRepository, ShippingClient and some BigDecimal arithmetic. What is the most sensible approach?
Pick one — you get feedback right away
109 / 145
Section
13. Error quick-reference
110 / 145

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.

111 / 145
Table
Error text (fragment)Real cause30-second fixWhere 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 caseDelete the redundant when; if lenient behaviour is genuinely needed, annotate the class with @MockitoSettings(strictness = Strictness.LENIENT) after understanding why it went unusedSection 5
Wanted but not invoked ... However, there were exactly 0 interactions with this mockThe call you expected never happened: the branch was not reached, the arguments did not match, or the dependency was not injected as a mockCheck 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 allSection 5
Wanted but not invoked ... there were exactly 2 interactions with this mockExpectation 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 loopSection 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 differentAdd equals/hashCode to the value object (or make it a record); alternatively assert field by field with argThat(...) or ArgumentCaptorSection 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 missingAdd @ExtendWith(MockitoExtension.class); or call MockitoAnnotations.openMocks(this) inside @BeforeEachSection 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 signatureReconcile placeholder indices with parameter order: {index} is the case number while {0} is the first argumentSection 7
No tests found for given includes, or Maven running zero testsThe class name breaks the Surefire convention (it must end in Test / Tests / TestCase), or the test lives under src/main/javaRename or move it; run it once from the IDE to confirm it is discoveredArticle 34
Failed to load ApplicationContextLooks like a unit test failing, but the class actually carries @SpringBootTest, so a wiring problem takes down a whole batchA pure unit test should never produce this: move container-backed cases into the integration package and run them under a separate profileArticle 34
Static-method or constructor mocking: a pile of MockedStatic boilerplate, or reaching for PowerMockNot a framework trap but a design signal — you depend on a collaborator that cannot be replacedPrefer 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 closesSection 6
112 / 145
Tip

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.

113 / 145

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:

114 / 145
Triage
Error triageIllegalStateException: Failed to load ApplicationContext
A 'unit test' that cannot boot a container: one screen, fourteen red cases

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.

java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@3f49dab testClass = com.bee.order.OrderServiceTest, locations = [], classes = [com.bee.order.OrderApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = []]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:145)
at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:124)
at org.springframework.test.context.web.ServletTestExecutionListener.setUpRequestContextIfNecessary(ServletTestExecutionListener.java:191)
at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:241)
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:158)
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSource' defined in class path resource [org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration$Hikari.class]: Failed to instantiate [com.zaxxer.hikari.HikariDataSource]: Factory method 'dataSource' threw exception with message: Failed to determine a suitable driver class
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:648)
Caused by: org.springframework.boot.autoconfigure.jdbc.DataSourceProperties$DataSourceBeanCreationException: Failed to determine a suitable driver class
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
115 / 145
Section
14. Three traps and a decision card
116 / 145
Trap

@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.

117 / 145
Trap

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.

118 / 145
Trap

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.

119 / 145
Decision
DecisionA junior asks: "Should the test class share the package and name of the subject? Would a separate package be tidier?"
120 / 145
Section
15. Exercises
121 / 145
Section
Tier 1 · Follow along
122 / 145

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:

123 / 145
java
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; }}
124 / 145
java
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}
125 / 145
java
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());    }}
126 / 145
java
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    }}
127 / 145

Expected output (mvn test, or run the class from your IDE):

128 / 145
text
[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_threshold
129 / 145

Two 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.

130 / 145
Section
Tier 2 · Variant
131 / 145

Goal: manufacture the friendliest error in Section 13 and read it yourself.

132 / 145

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.

133 / 145

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.

134 / 145
Section
Tier 3 · Build one
135 / 145

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.

136 / 145

Acceptance checklist:

137 / 145
  • [ ] At least 8 cases, half of them parameterized, covering "exactly at the threshold", "one cent below", "coupon expired" and "percentage would exceed 50"
  • [ ] Coupon is a record or overrides equals/hashCode, so verify(couponRepository).findById(3L) passes instead of failing with Argument(s) are different
  • [ ] Time comes from an injected Clock pinned with Clock.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
138 / 145
Section
16. Self-check
139 / 145
自检

from memory, draw the order in Section 3: @BeforeAll → (@BeforeEach → @Test → @AfterEach) × N → @AfterAll, and say why @BeforeAll must be static.

140 / 145
自检

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.

141 / 145
自检

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.

142 / 145
自检

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?

143 / 145
自检

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.

144 / 145
口诀

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()).

145 / 145
Summary

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.