JUnit 5 & Mockito: Testing That Catches Bugs
It's the Monday after Black Friday. Your team's checkout service has been live for three weeks, and every test you have is a person clicking through the demo. At 11:40 a support ticket lands: a customer bought a $219.00 espresso machine and was charged $3.17. Then another ticket. Then thirty. By noon someone finds it: the new coupon feature applies the percentage discount once per line item and once more to the order total. Stack a 20% coupon with a gift card, and the discount multiplies. Nobody tested that combination, because "testing" meant a senior dev squinting at the code and saying "looks right."
The fix took eleven minutes. The refunds took two weeks. And the uncomfortable lesson is the ratio every experienced engineer eventually internalizes: a bug caught by a unit test costs minutes; the same bug caught by a customer costs days. Unit tests are the cheapest bug-catcher you will ever buy — and in Java, JUnit 5 plus Mockito is the standard-issue kit.
This post is unapologetically practical. You will write real tests against real code, see real output, and leave with a test class you could commit to a repository today. One boundary to set up front: this post is about unit testing — fast, in-memory tests of one class at a time. Tests that need a real database belong to Testcontainers: Real Databases in Tests, later in this track.
Your first JUnit 5 test: 5 minutes, one dependency
JUnit 5 (a.k.a. JUnit Jupiter) is the current generation of Java's standard testing framework. Add it to your build once, in test scope so it never ships to production. (The Maven mechanics are covered in this track's Maven vs Gradle: Builds Demystified; here we just need the dependency.)
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
Now the class under test. This is the heart of the Black Friday story, reduced to its essence — a discount calculator with one job and several ways to get it wrong:
package com.acme.shop;
import java.math.BigDecimal;
import java.math.RoundingMode;
public final class PriceCalculator {
/**
* Applies a percentage discount to a subtotal.
* discountRate is 0.10 for 10% — must be between 0 and 1 inclusive.
*/
public BigDecimal applyDiscount(BigDecimal subtotal, BigDecimal discountRate) {
if (subtotal.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("subtotal must not be negative");
}
if (discountRate.compareTo(BigDecimal.ZERO) < 0
|| discountRate.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("discountRate must be between 0 and 1");
}
return subtotal.multiply(BigDecimal.ONE.subtract(discountRate))
.setScale(2, RoundingMode.HALF_UP);
}
}
A few deliberate choices here, worth noticing: BigDecimal instead of double for money (the Date, Time & Money post explains why floating point and currency are enemies), input validation up front, and the result pinned to 2 decimal places with explicit rounding — money should never carry four fractional digits into a database. Now the test, in src/test/java/com/acme/shop/PriceCalculatorTest.java:
package com.acme.shop;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PriceCalculatorTest {
private final PriceCalculator calc = new PriceCalculator();
@Test
void tenPercentOff100Is90() {
BigDecimal result = calc.applyDiscount(
new BigDecimal("100.00"), new BigDecimal("0.10"));
assertEquals(new BigDecimal("90.00"), result);
}
@Test
void zeroDiscountLeavesTotalUnchanged() {
BigDecimal result = calc.applyDiscount(
new BigDecimal("59.99"), BigDecimal.ZERO);
assertEquals(new BigDecimal("59.99"), result);
}
@Test
void negativeSubtotalIsRejected() {
assertThrows(IllegalArgumentException.class, () ->
calc.applyDiscount(new BigDecimal("-5.00"), new BigDecimal("0.10")));
}
}
Run it:
mvn test
[INFO] Running com.acme.shop.PriceCalculatorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Three conventions to absorb: test classes live in src/test/java mirroring the package of the class they test; test methods are annotated @Test and named as sentences describing the behavior (tenPercentOff100Is90, not test1); and every test follows arrange → act → assert, even when it's one line. Note the sharp edge we sidestepped in applyDiscount: BigDecimal.equals compares scale as well as value, so new BigDecimal("90.0000") does not equal new BigDecimal("90.00"). The setScale(2, ...) in production code is what makes assertEquals honest here — a test that forced a better design.
This is the test pyramid, and it explains where this post lives: the broad base. Unit tests are cheap to write, run in milliseconds, and fail for exactly one reason — the unit broke. Everything above the base is slower, flakier, and more expensive. Teams that invert the pyramid (a few unit tests, hundreds of end-to-end tests) get a suite that takes an hour and fails for weather. Build the base first.
Assertions that say something
assertEquals and assertThrows carry you surprisingly far, but JUnit 5's assertion vocabulary is worth learning in full, because a precise assertion is documentation that never rots:
assertEquals(expected, actual)— values. Fordoublethere is a three-arg overload with a delta; for money, preferBigDecimalas above.assertTrue/assertFalse— boolean conditions. Push real logic here, notassertEquals(true, ...).assertNull/assertNotNull— the "did it return something at all" checks.assertThrows(IllegalArgumentException.class, () -> ...)— returns the exception, so you can assert on its message too:
The exception types themselves are covered in the Exceptions post — what matters here is that illegal input gets a loud, testable failure instead of silent garbage.IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> calc.applyDiscount(new BigDecimal("-5.00"), new BigDecimal("0.10"))); assertTrue(ex.getMessage().contains("negative"));assertDoesNotThrow(() -> ...)— for the "this edge case must simply work" cases.
Decision rule: assert on strong>observable behavior (return values, thrown exceptions, state changes), never on implementation (private methods, internal call counts). A test that breaks when you refactor without changing behavior is a test that punishes improvement — trap 1 below.
Structure: lifecycle, parameterized tests, @Nested
Three tests become thirty fast. JUnit 5 gives you structure so the suite stays readable.
Lifecycle. @BeforeEach runs before every test method — use it to build fresh fixtures so tests can't leak state into each other. @AfterEach is its cleanup counterpart (close files, reset statics). @BeforeAll / @AfterAll run once per class and must be static (unless you opt into per-class lifecycle) — reserve them for genuinely expensive one-time setup.
class PriceCalculatorTest {
private PriceCalculator calc;
@BeforeEach
void setUp() {
calc = new PriceCalculator(); // fresh instance, every test
}
@ParameterizedTest
@ValueSource(strings = {"0", "0.10", "0.50", "1.00"})
void validDiscountRatesAreAccepted(String rate) {
assertDoesNotThrow(() ->
calc.applyDiscount(new BigDecimal("100.00"), new BigDecimal(rate)));
}
@ParameterizedTest
@CsvSource({
"100.00, 0.10, 90.00",
"100.00, 0.25, 75.00",
"59.99, 0.50, 30.00", // 29.995 rounds HALF_UP to 30.00
"200.00, 0.00, 200.00"
})
void discountTable(String subtotal, String rate, String expected) {
assertEquals(new BigDecimal(expected),
calc.applyDiscount(new BigDecimal(subtotal), new BigDecimal(rate)));
}
}
@ParameterizedTest with @ValueSource or @CsvSource turns one test method into many cases — JUnit reports each row separately, so a failing row tells you exactly which input broke. That CSV table is the Black Friday bug-catcher: it takes ten seconds to add the row "219.00, 0.20, 175.20" that would have caught the double-discount, and from then on that row runs on every build, forever.
@Nested groups related tests with their own lifecycle, which reads beautifully for "the validation rules" vs "the happy path":
@Nested
class Validation {
@Test
void rateAboveOneIsRejected() {
assertThrows(IllegalArgumentException.class, () ->
calc.applyDiscount(new BigDecimal("100.00"), new BigDecimal("1.50")));
}
@Test
void negativeRateIsRejected() {
assertThrows(IllegalArgumentException.class, () ->
calc.applyDiscount(new BigDecimal("100.00"), new BigDecimal("-0.01")));
}
}
Nested classes get their own @BeforeEach too, and the test report renders them as a tree. Use them whenever a test class starts feeling like two test classes wearing a trench coat.
Mockito: testing the unit, not the universe
PriceCalculator is pure logic — no network, no database, no clock. Most production classes aren't. The moment your class calls a payment API, reads from a database, or asks what time it is, a unit test has a problem: you can't unit-test "charge the card" by actually charging cards.
Enter Mockito: it creates a fake implementation of an interface (or class) that you program with canned answers, so the unit under test runs in a sealed room. The payment gateway answers instantly, the database never flakes, and the test runs in 12 milliseconds. Add the two artifacts (current 5.x line):
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
The decision rule for what to mock — the single most misapplied idea in unit testing:
Why the split? A mock of a database proves nothing about your code — it proves your code agrees with your imagination of the database. But a mock of a boundary lets you simulate the boundary's every mood — success, decline, timeout — without the boundary's cost or flakiness. Meanwhile your own domain classes are cheap to instantiate and deterministic; using the real ones keeps the test testing reality. If you find yourself mocking PriceCalculator to test the class that uses it, stop: you're testing that your code calls code, which is trap 2.
The core Mockito vocabulary is three verbs:
mock(PaymentGateway.class)— create the fake.when(gateway.charge(...)).thenReturn(result)— stub it: "when called like this, answer that." Forvoidmethods the syntax flips:doThrow(new RuntimeException()).when(gateway).refund(...).verify(gateway).charge("tok_visa", amount)— verify the interaction happened, with exactly those arguments.verify(gateway, times(2))andverify(gateway, never())cover the rest;verifyNoInteractions(gateway)asserts the boundary was never touched.
The modern style avoids calling mock() by hand: annotate fields with @Mock, annotate the class under test with @InjectMocks (Mockito constructs it and injects the mocks), and register the MockitoExtension so JUnit wires it all up. That's the style the centerpiece uses.
The centerpiece: OrderService, fully tested
Time to put it together. An order service with a real dependency on a payment gateway — the classic shape of production Java. First the production code:
package com.acme.shop;
import java.math.BigDecimal;
public interface PaymentGateway {
ChargeResult charge(String cardToken, BigDecimal amount);
void refund(String chargeId, BigDecimal amount);
}
package com.acme.shop;
public record ChargeResult(String chargeId, boolean success, String message) { }
package com.acme.shop;
public class PaymentFailedException extends RuntimeException {
public PaymentFailedException(String message) {
super(message);
}
}
package com.acme.shop;
import java.math.BigDecimal;
import java.util.Objects;
public final class OrderService {
private final PaymentGateway gateway;
public OrderService(PaymentGateway gateway) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
}
/**
* Charges the card and returns the gateway's charge id.
* @throws IllegalArgumentException if the inputs are invalid
* @throws PaymentFailedException if the gateway declines the charge
*/
public String placeOrder(String cardToken, BigDecimal amount) {
if (cardToken == null || cardToken.isBlank()) {
throw new IllegalArgumentException("cardToken must not be blank");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
ChargeResult result = gateway.charge(cardToken, amount);
if (!result.success()) {
throw new PaymentFailedException("Charge declined: " + result.message());
}
return result.chargeId();
}
}
Constructor injection is what makes this testable: the dependency arrives as a parameter, so tests can hand in a mock instead of the real gateway. (If you're coming from Python or C#, this is the same idea as passing a fake into the constructor — Java just makes the types explicit.) Now the test class — the whole thing, imports included:
package com.acme.shop;
import org.junit.jupiter.api.DisplayName;
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.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
PaymentGateway gateway;
@InjectMocks
OrderService service;
@Test
@DisplayName("approved charge returns the gateway's charge id")
void approvedChargeReturnsChargeId() {
when(gateway.charge(eq("tok_visa"), eq(new BigDecimal("49.99"))))
.thenReturn(new ChargeResult("ch_123", true, "approved"));
String chargeId = service.placeOrder("tok_visa", new BigDecimal("49.99"));
assertEquals("ch_123", chargeId);
verify(gateway).charge("tok_visa", new BigDecimal("49.99"));
verifyNoMoreInteractions(gateway);
}
@Test
@DisplayName("declined charge throws PaymentFailedException with the gateway's reason")
void declinedChargeThrows() {
when(gateway.charge(anyString(), any(BigDecimal.class)))
.thenReturn(new ChargeResult(null, false, "insufficient funds"));
PaymentFailedException ex = assertThrows(PaymentFailedException.class, () ->
service.placeOrder("tok_visa", new BigDecimal("10.00")));
assertTrue(ex.getMessage().contains("insufficient funds"));
verify(gateway).charge("tok_visa", new BigDecimal("10.00"));
}
@ParameterizedTest
@ValueSource(strings = {"", " "})
@DisplayName("blank card token is rejected before touching the gateway")
void blankCardTokenRejected(String token) {
assertThrows(IllegalArgumentException.class, () ->
service.placeOrder(token, new BigDecimal("5.00")));
verifyNoInteractions(gateway);
}
@ParameterizedTest
@CsvSource({"0", "-1", "-0.01"})
@DisplayName("non-positive amount is rejected before touching the gateway")
void nonPositiveAmountRejected(String amount) {
assertThrows(IllegalArgumentException.class, () ->
service.placeOrder("tok_visa", new BigDecimal(amount)));
verifyNoInteractions(gateway);
}
@Test
@DisplayName("the exact amount passed to the gateway is captured and checked")
void capturesAmountSentToGateway() {
when(gateway.charge(anyString(), any(BigDecimal.class)))
.thenReturn(new ChargeResult("ch_9", true, "approved"));
service.placeOrder("tok_mc", new BigDecimal("19.95"));
ArgumentCaptor<BigDecimal> amounts = ArgumentCaptor.forClass(BigDecimal.class);
verify(gateway).charge(eq("tok_mc"), amounts.capture());
assertEquals(new BigDecimal("19.95"), amounts.getValue());
}
}
[INFO] Running com.acme.shop.OrderServiceTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Walk through what this proves, because it's the whole craft in one class:
- Stubbing (
when...thenReturn): the happy path and the decline path, both simulated without a network.eq()andany()are argument matchers — one rule: once you use a matcher for one argument, use matchers for all arguments in that call, or Mockito throws. - Verification (
verify): not just "did it return the right thing" but "did it talk to the boundary correctly" — the exact token and amount.verifyNoMoreInteractionscatches the "charged twice" class of bug from our Black Friday story. - Negative verification (
verifyNoInteractions): the validation tests prove invalid input is rejected before any money moves. This is a genuinely valuable test — it locks in the ordering guarantee. - ArgumentCaptor: when the assertion is about what was passed rather than what came back, capture the argument and inspect it. The captor is the answer to "I don't care about the return value, I care about the exact cents sent to the gateway."
JaCoCo: prove the tests run the code
Seven green tests feel great. But here's an uncomfortable question: is there a line of OrderService that no test executes? Coverage tools answer it mechanically. JaCoCo instruments your bytecode, watches which lines run during mvn test, and reports the gaps. Add it to your build:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</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>
Then mvn verify. JaCoCo writes an HTML report to target/site/jacoco/index.html — open it and you'll see every class color-coded: green lines executed, red lines never touched, yellow branches partially covered (e.g. an if whose false-branch no test reached). For our OrderService, the report would show the !result.success() branch fully green — both sides tested — which is exactly the branch the Black Friday team never exercised.
The report is for humans; the gate is for the build. Add a check execution that fails the build when coverage drops below your bar — 80% line coverage is the industry's common starting point:
<execution>
<id>coverage-check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
Now a coverage regression fails the build loudly:
[INFO] --- jacoco:0.8.12:check (coverage-check) @ shop ---
[INFO] Rule violated for bundle shop: lines covered ratio is 0.74, but expected minimum is 0.80
[ERROR] Failed to execute goal org.jacoco:jacoco-maven-plugin:0.8.12:check (coverage-check) on project shop
Two honest caveats. First, coverage measures execution, not correctness — a test with no assertions gives you 100% coverage and 0% confidence. The gate keeps you honest about running the code; only good assertions keep you honest about the behavior. Second, wire the gate into CI (this track's CI/CD with GitHub Actions for Java Projects shows the mechanics) so it runs on every pull request, not just on the machine of whoever remembers.
When it breaks: 5 testing traps
Trap 1 — Testing implementation details: the brittle suite
Symptom: you rename a private method or reorder two internal calls, and twelve tests turn red — even though the behavior is identical. The tests were asserting how the code works (via reflection into privates, or verify(mock, times(3)) on an internal collaborator) instead of what it does. Diagnose it: if a pure refactor with no behavior change breaks tests, those tests are coupled to the implementation. Fix: assert on public behavior — return values, thrown exceptions, and interactions at the boundary (the real payment gateway), not on internal plumbing. Refactoring should be a safe activity; a brittle suite makes it a scary one.
Trap 2 — Mocking everything, including your own code
Symptom: the test for OrderService mocks PriceCalculator, stubs applyDiscount to return new BigDecimal("90.00"), and passes — while the real PriceCalculator has a bug that ships to production. The test verified that your code calls your code, which is a tautology with extra steps. Diagnose it: if removing the mock and using new PriceCalculator() makes the test simpler and stronger, the mock was wrong. Fix: apply the decision rule from the diagram above — mock external boundaries (database, HTTP, clock), use real instances of your own domain classes and value objects. A mock should replace something painful, not something convenient.
Trap 3 — The flaky test: time, randomness, and shared state
Symptom: the suite is green on your machine, red on CI, green again on retry. The usual suspects: Instant.now() baked into assertions (fails at midnight, or when CI is slow), unseeded new Random(), or static mutable state leaking between tests (a static cache populated by test A changes test B's outcome — and JUnit doesn't guarantee method order). Diagnose it: run the suite with mvn -Dsurefire.runOrder=random test; if failures move around, you have order dependence. Fix: inject a java.time.Clock instead of calling now() directly (then tests use Clock.fixed(...)), seed your Random, and never share mutable state between tests — @BeforeEach exists precisely for this.
Trap 4 — Testing async code without waiting
Symptom: a test starts a background thread or submits a task, asserts immediately, and fails — or worse, passes nine times out of ten. Thread.sleep(500) "fixes" it until CI gets slow and 500ms isn't enough. Diagnose it: any assertion that depends on work happening on another thread, without synchronization, is a race. Fix: wait properly. The standard tool is Awaitility — poll until a condition holds, with a real timeout:
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.2</version>
<scope>test</scope>
</dependency>
import static org.awaitility.Awaitility.await;
import java.time.Duration;
await().atMost(Duration.ofSeconds(5))
.until(() -> orderRepository.findById("ord_1").isPresent());
This waits up to 5 seconds but returns the instant the condition is true — fast when healthy, honest when broken. Never Thread.sleep in a test and hope.
Trap 5 — @Disabled as a permanent solution
Symptom: the suite has three tests annotated @Disabled("flaky, fix later"), and "later" was eight months ago. Disabled tests are worse than no tests: they show up green, they rot (the code they tested has changed beyond recognition), and everyone assumes the area is covered. Diagnose it: grep -r "@Disabled" src/test — if the count grows over time, the team has a quarantine problem, not a test problem. Fix: every disabled test gets either a fix within the sprint or a deletion. A test you won't fix is a test you should delete — an honest gap in the JaCoCo report beats a comforting lie in the test count.
What's next
You now have the complete unit-testing loop: JUnit 5 tests that read like specifications, Mockito fakes that isolate the unit from its boundaries, parameterized tables that lock in edge cases forever, and a JaCoCo gate that fails the build when coverage slips. The habit to build is writing the test for the bug you just fixed — every production bug becomes a regression test, and the suite becomes a record of every lesson the codebase has learned.
But unit tests have a hard limit: the mock is your imagination of the database. The query that works against your mental model of Postgres and fails against real Postgres is a bug no mock can catch. That's the next layer of the pyramid: integration tests against real databases, spun up in containers, in Testcontainers: Real Databases in Tests.
Field check before you move on: take the OrderServiceTest above, run it with mvn test, then delete one assertion (say, the verifyNoInteractions in blankCardTokenRejected) and confirm the suite still passes — that's the feeling of a test that isn't pulling its weight. Now add a JaCoCo gate at 80% to your own project and run mvn verify. If the build fails, don't lower the number: write the missing tests until it's green. That's the gate doing its job.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment