Unit Testing with JUnit 5 & Mockito
Unit testing with JUnit 5 and Mockito means testing one class, such as a Spring Boot service, on its own, with its collaborators replaced by mocks. A mock is a stand-in object whose answers you script in the test, so you can check how your class reacts when the payment is approved, the stock runs out or a product doesn't exist. These tests need no database, no network and no Spring context, so hundreds of them run in seconds and point straight at the line that broke.
This tutorial explains where unit tests fit next to Spring Boot's other test types and how JUnit and Mockito work together. It covers the JUnit essentials (assertions, lifecycle, @Nested, parameterized tests) and the Mockito essentials: @Mock vs @InjectMocks, stubbing, verifying, argument captors and matchers. It then unit-tests a complete order service on Spring Boot 4.1 and explains the real Mockito errors you are most likely to meet.
1. What Is Unit Testing in Spring Boot?
A unit test checks a single class through its public methods. Everything the class depends on, such as repositories, HTTP clients and payment gateways, is replaced by a test double, so the test controls every input and sees every output. The class is created with new, not by Spring.
Two libraries do the work. JUnit finds and runs the test methods and reports the results. Mockito creates the mocks, lets you script their answers (stubbing) and checks how they were called (verification). Classes written with constructor injection are easy to unit-test, because the test can pass mocks straight into the constructor.
2. Unit Tests vs Slice Tests vs @SpringBootTest
| Unit test | Slice test (@WebMvcTest, @DataJpaTest) | @SpringBootTest | |
|---|---|---|---|
| Spring context | None | Part of it | All of it |
| Collaborators | Mockito mocks (@Mock) | Real beans in the slice, mocks (@MockitoBean) for the rest | Real beans, some replaced with @MockitoBean |
| Speed per test | Milliseconds | Context start plus milliseconds | Full context start plus milliseconds |
| Finds | Logic and branching bugs | Mapping, JSON, SQL and validation bugs | Wiring and configuration bugs |
Most of your tests should be unit tests, backed by a smaller number of slice and integration tests. The Spring Boot testing overview covers all three, and the Testcontainers tutorial covers testing against real databases.
3. How JUnit and Mockito Work Together
- JUnit creates a new instance of the test class for every test method, so tests can't leak field values into each other.
@ExtendWith(MockitoExtension.class)plugs Mockito into that lifecycle. Before each test it creates a fresh mock for every@Mockfield.- Your
@BeforeEachmethod builds the class under test with those mocks. - The test stubs the answers it needs, calls the real method, then checks the result and the calls made on the mocks.
- After each test, Mockito's strict stubs mode, the default with the extension, fails the test if a stub was never used (section 8.2).
4. Setup
spring-boot-starter-test brings everything in one dependency, with versions managed by Spring Boot:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
| Library | Version in Spring Boot 4.1.2 | Used for |
|---|---|---|
| JUnit Jupiter | 6.0.3 | Running tests, assertions, parameterized tests |
| Mockito (core and JUnit extension) | 5.23.0 | Mocks, stubbing, verification |
| AssertJ | 3.27.7 | Readable, fluent assertions |
| Hamcrest, JSONassert, JsonPath, XMLUnit, Awaitility, Spring Test | Managed | Matchers, JSON and XML checks, waiting for async results, Spring test support |
org.junit.jupiter packages as JUnit 5, so everything on this page applies to both. It also raises the minimum Java version to 17. For Spring tests, @MockBean was removed in Spring Boot 4. Use @MockitoBean from org.springframework.test.context.bean.override.mockito.On JDK 21 and later, Mockito prints a warning that it is "self-attaching" a Java agent, a mechanism that future JDKs will block. Register Mockito as an agent in the build instead:
<build>
<plugins>
<!-- Exposes the path of each dependency jar as a Maven property -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Loads Mockito as a Java agent instead of letting it attach itself at runtime -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
</plugins>
</build>
5. JUnit 5 Essentials
5.1 Test Methods and Assertions
A test is a method annotated with @Test. It doesn't need to be public, and neither does the class. Use AssertJ's assertThat for checks: the messages are clear, and code completion shows every available check. For exceptions, assertThatThrownBy checks the type and the message in one go:
assertThat(confirmation).isEqualTo(
new OrderConfirmation("KB-100", 2, new BigDecimal("178.00"), "TX-9001"));
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest("C-42", "XX-999", 1)))
.isInstanceOf(ProductNotFoundException.class)
.hasMessage("Product not found: XX-999");
5.2 Lifecycle Annotations
| Annotation | Runs | Typical use |
|---|---|---|
@BeforeEach | Before every test method | Create the class under test with fresh mocks |
@AfterEach | After every test method | Clean up files or static state |
@BeforeAll / @AfterAll | Once per class (static methods) | Expensive shared setup |
@Disabled | Never | Temporarily skip a test, with a reason |
5.3 Readable Tests with @DisplayName and @Nested
@DisplayName gives a test a sentence-like name in reports. @Nested groups tests that share a situation into an inner class with its own @BeforeEach, which runs after the outer one. In the example, the HappyPath class stubs the price and stock once for all three of its tests.
5.4 Parameterized Tests
@ParameterizedTest runs one test method once per row of input. @ValueSource supplies single values, @CsvSource supplies rows, and JUnit converts each column to the parameter type, here including BigDecimal. Boundary values belong in these tables:
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class OrderTotalTest {
@DisplayName("Order total with 10% off from 1000.00")
@ParameterizedTest(name = "{1} x {0} = {2}")
@CsvSource({
"89.00, 1, 89.00", // no discount
"333.33, 3, 999.99", // just below the threshold
"500.00, 2, 900.00", // exactly 1000.00: discount applies
"120.00, 10, 1080.00" // well above the threshold
})
void totalAppliesDiscountFromThreshold(BigDecimal unitPrice, int quantity, BigDecimal expected) {
assertThat(OrderService.totalFor(unitPrice, quantity)).isEqualTo(expected);
}
}
For complex objects, @MethodSource("name") reads the arguments from a static method that returns a Stream.
6. Mockito Essentials
6.1 @Mock vs @InjectMocks vs @Spy vs @MockitoBean
| Annotation | Creates | Use it for |
|---|---|---|
@Mock | A fake whose methods return defaults (null, 0, false, empty Optional or collection) until stubbed | Every collaborator of the class under test |
@InjectMocks | The real class under test, with the @Mock fields passed to its constructor | Classes whose dependencies are all mocks |
@Spy | A real object whose methods you can partly stub and verify | Rarely; usually a sign the class does too much |
@Captor | An ArgumentCaptor | Inspecting the objects passed to a mock |
@MockitoBean (Spring) | A mock that replaces a bean in the Spring context | Slice tests and @SpringBootTest, not plain unit tests |
The example builds OrderService by hand in @BeforeEach instead of using @InjectMocks. One of its dependencies is a real fixed Clock, not a mock, and @InjectMocks would silently pass null for it (section 8.5).
6.2 Stubbing: given() and when()
Stubbing tells a mock what to return. Mockito has two spellings that do the same thing. The BDD style from BDDMockito reads naturally in "given / when / then" tests, and this page uses it:
| Purpose | Classic (Mockito) | BDD (BDDMockito) |
|---|---|---|
| Return a value | when(mock.call()).thenReturn(x) | given(mock.call()).willReturn(x) |
| Throw | when(mock.call()).thenThrow(ex) | given(mock.call()).willThrow(ex) |
| Compute the answer | thenAnswer(inv -> ...) | willAnswer(inv -> ...) |
| Verify a call | verify(mock).call() | then(mock).should().call() |
willAnswer(invocation -> invocation.getArgument(0)) makes a mocked save return whatever it was given, a common pattern for repositories.
6.3 Verifying Interactions
When a method's job is a side effect, such as charging a card or releasing stock, verify the call. Verify what must not happen just as carefully:
// The payment was declined: stock must be released and nothing saved
then(inventory).should().release("KB-100", 2);
then(orders).should(never()).save(any());
// Out of stock: the customer must never be charged
verifyNoInteractions(payments, orders);
// Order matters: reserve, then charge, then save
InOrder steps = inOrder(inventory, payments, orders);
steps.verify(inventory).reserve("KB-100", 2);
steps.verify(payments).charge("C-42", new BigDecimal("178.00"));
steps.verify(orders).save(any(Order.class));
Don't verify every stubbed call. If a stubbed value is used in the result, checking the result already proves the call happened.
6.4 Capturing Arguments with ArgumentCaptor
When the class under test builds an object and passes it on, an ArgumentCaptor grabs that object so you can check its fields:
@Captor
ArgumentCaptor<Order> savedOrder;
// ...
then(orders).should().save(savedOrder.capture());
assertThat(savedOrder.getValue().placedAt()).isEqualTo(NOW);
assertThat(savedOrder.getValue().transactionId()).isEqualTo("TX-9001");
6.5 Argument Matchers
Matchers such as any(), anyString(), eq(...) and argThat(...) match arguments loosely. Use exact values when they matter to the test, and matchers when they don't. In one call, either all arguments are matchers or none are (section 8.3). Plain values are compared with equals, which matters for BigDecimal: new BigDecimal("178") does not equal new BigDecimal("178.00") (section 8.4).
7. A Complete Example: Unit Testing an Order Service
OrderService.placeOrder validates the quantity, looks up the price, applies a 10% discount from 1000.00, reserves stock, charges the customer, and saves the order. If the payment is declined, it releases the stock again. Each step can fail, and each failure must stop the steps after it. The service only throws domain exceptions; turning them into HTTP responses belongs to the web layer, as shown in the exception handling tutorial:
7.1 The Code Under Test
package com.javabytechie.shop.order;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
static final BigDecimal DISCOUNT_THRESHOLD = new BigDecimal("1000.00");
static final BigDecimal DISCOUNT_RATE = new BigDecimal("0.10");
private final ProductCatalog catalog;
private final InventoryClient inventory;
private final PaymentGateway payments;
private final OrderRepository orders;
private final Clock clock;
public OrderService(ProductCatalog catalog, InventoryClient inventory, PaymentGateway payments,
OrderRepository orders, Clock clock) {
this.catalog = catalog;
this.inventory = inventory;
this.payments = payments;
this.orders = orders;
this.clock = clock;
}
public OrderConfirmation placeOrder(PlaceOrderRequest request) {
if (request.quantity() < 1 || request.quantity() > 10) {
throw new InvalidQuantityException(request.quantity());
}
BigDecimal unitPrice = catalog.findPrice(request.sku())
.orElseThrow(() -> new ProductNotFoundException(request.sku()));
BigDecimal total = totalFor(unitPrice, request.quantity());
if (!inventory.reserve(request.sku(), request.quantity())) {
throw new OutOfStockException(request.sku(), request.quantity());
}
PaymentResult payment = payments.charge(request.customerId(), total);
if (!payment.approved()) {
// Give the stock back, otherwise it stays reserved for an order that never happened
inventory.release(request.sku(), request.quantity());
throw new PaymentDeclinedException(request.customerId());
}
Order saved = orders.save(new Order(request.customerId(), request.sku(), request.quantity(),
total, payment.transactionId(), clock.instant()));
log.info("Order placed: {} x {} for {} ({})", saved.quantity(), saved.sku(), saved.total(), saved.transactionId());
return new OrderConfirmation(saved.sku(), saved.quantity(), saved.total(), saved.transactionId());
}
// Orders of 1000.00 or more get 10% off
static BigDecimal totalFor(BigDecimal unitPrice, int quantity) {
BigDecimal subtotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
if (subtotal.compareTo(DISCOUNT_THRESHOLD) >= 0) {
subtotal = subtotal.subtract(subtotal.multiply(DISCOUNT_RATE));
}
return subtotal.setScale(2, RoundingMode.HALF_UP);
}
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
import java.util.Optional;
public interface ProductCatalog {
Optional<BigDecimal> findPrice(String sku);
}
package com.javabytechie.shop.order;
public interface InventoryClient {
// true if the stock was reserved, false if there was not enough
boolean reserve(String sku, int quantity);
void release(String sku, int quantity);
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
public interface PaymentGateway {
PaymentResult charge(String customerId, BigDecimal amount);
}
package com.javabytechie.shop.order;
public interface OrderRepository {
Order save(Order order);
}
package com.javabytechie.shop.order;
// PlaceOrderRequest.java
public record PlaceOrderRequest(String customerId, String sku, int quantity) {
}
// PaymentResult.java
public record PaymentResult(String transactionId, boolean approved) {
}
// Order.java
import java.math.BigDecimal;
import java.time.Instant;
public record Order(String customerId, String sku, int quantity, BigDecimal total,
String transactionId, Instant placedAt) {
}
// OrderConfirmation.java
import java.math.BigDecimal;
public record OrderConfirmation(String sku, int quantity, BigDecimal total, String transactionId) {
}
package com.javabytechie.shop.order;
// InvalidQuantityException.java
public class InvalidQuantityException extends RuntimeException {
public InvalidQuantityException(int quantity) {
super("Quantity must be between 1 and 10, but was " + quantity);
}
}
// ProductNotFoundException.java
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(String sku) {
super("Product not found: " + sku);
}
}
// OutOfStockException.java
public class OutOfStockException extends RuntimeException {
public OutOfStockException(String sku, int quantity) {
super("Not enough stock for " + sku + ": requested " + quantity);
}
}
// PaymentDeclinedException.java
public class PaymentDeclinedException extends RuntimeException {
public PaymentDeclinedException(String customerId) {
super("Payment declined for customer " + customerId);
}
}
7.2 The Unit Test
Every path in Figure 2 has a test. Each one checks both the result and what did, or didn't, happen to the collaborators:
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
private static final Instant NOW = Instant.parse("2026-10-25T09:30:00Z");
@Mock
ProductCatalog catalog;
@Mock
InventoryClient inventory;
@Mock
PaymentGateway payments;
@Mock
OrderRepository orders;
@Captor
ArgumentCaptor<Order> savedOrder;
OrderService orderService;
@BeforeEach
void setUp() {
// A fixed clock makes placedAt predictable; the mocks come from MockitoExtension
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
orderService = new OrderService(catalog, inventory, payments, orders, clock);
}
@Nested
@DisplayName("when the product exists and is in stock")
class HappyPath {
@BeforeEach
void stockAndPrice() {
given(catalog.findPrice("KB-100")).willReturn(Optional.of(new BigDecimal("89.00")));
given(inventory.reserve("KB-100", 2)).willReturn(true);
}
@Test
@DisplayName("charges the customer and saves the order")
void placesOrder() {
given(payments.charge("C-42", new BigDecimal("178.00"))).willReturn(new PaymentResult("TX-9001", true));
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
OrderConfirmation confirmation = orderService.placeOrder(new PlaceOrderRequest("C-42", "KB-100", 2));
assertThat(confirmation).isEqualTo(new OrderConfirmation("KB-100", 2, new BigDecimal("178.00"), "TX-9001"));
then(orders).should().save(savedOrder.capture());
assertThat(savedOrder.getValue().placedAt()).isEqualTo(NOW);
assertThat(savedOrder.getValue().transactionId()).isEqualTo("TX-9001");
}
@Test
@DisplayName("reserves stock before charging, and charges before saving")
void callsCollaboratorsInOrder() {
given(payments.charge(anyString(), any())).willReturn(new PaymentResult("TX-9002", true));
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
orderService.placeOrder(new PlaceOrderRequest("C-42", "KB-100", 2));
InOrder steps = inOrder(inventory, payments, orders);
steps.verify(inventory).reserve("KB-100", 2);
steps.verify(payments).charge("C-42", new BigDecimal("178.00"));
steps.verify(orders).save(any(Order.class));
}
@Test
@DisplayName("releases the stock and saves nothing when the payment is declined")
void releasesStockWhenPaymentDeclined() {
given(payments.charge(anyString(), any())).willReturn(new PaymentResult(null, false));
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest("C-42", "KB-100", 2)))
.isInstanceOf(PaymentDeclinedException.class)
.hasMessage("Payment declined for customer C-42");
then(inventory).should().release("KB-100", 2);
then(orders).should(never()).save(any());
}
}
@Test
@DisplayName("charges the discounted total for large orders")
void chargesDiscountedTotal() {
given(catalog.findPrice("MN-500")).willReturn(Optional.of(new BigDecimal("120.00")));
given(inventory.reserve("MN-500", 10)).willReturn(true);
given(payments.charge(anyString(), any())).willReturn(new PaymentResult("TX-9003", true));
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
orderService.placeOrder(new PlaceOrderRequest("C-42", "MN-500", 10));
then(payments).should().charge("C-42", new BigDecimal("1080.00"));
}
@Test
@DisplayName("does not charge when the product is out of stock")
void outOfStock() {
given(catalog.findPrice("KB-100")).willReturn(Optional.of(new BigDecimal("89.00")));
given(inventory.reserve("KB-100", 5)).willReturn(false);
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest("C-42", "KB-100", 5)))
.isInstanceOf(OutOfStockException.class);
verifyNoInteractions(payments, orders);
}
@Test
@DisplayName("fails fast for an unknown product")
void unknownProduct() {
given(catalog.findPrice("XX-999")).willReturn(Optional.empty());
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest("C-42", "XX-999", 1)))
.isInstanceOf(ProductNotFoundException.class)
.hasMessage("Product not found: XX-999");
verifyNoInteractions(inventory, payments, orders);
}
@ParameterizedTest(name = "quantity {0} is rejected")
@ValueSource(ints = {0, -1, 11})
void rejectsInvalidQuantity(int quantity) {
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest("C-42", "KB-100", quantity)))
.isInstanceOf(InvalidQuantityException.class);
verifyNoInteractions(catalog, inventory, payments, orders);
then(inventory).should(never()).reserve(anyString(), anyInt());
}
}
A fixed Clock makes the saved timestamp predictable. That is why the service takes a Clock instead of calling Instant.now().
7.3 Run the Tests
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderTotalTest
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderServiceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderServiceSpringTest
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
After the first test has loaded Mockito, each unit test took 1 to 5 milliseconds. For comparison, the same happy path as a @SpringBootTest with four @MockitoBean fields first had to start the application context, which took 0.22 seconds even for this tiny app. In real applications with a database and web layer, that start takes several seconds.
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
// The same scenario with the Spring context, to compare startup cost with the plain unit test
@SpringBootTest
class OrderServiceSpringTest {
@MockitoBean
ProductCatalog catalog;
@MockitoBean
InventoryClient inventory;
@MockitoBean
PaymentGateway payments;
@MockitoBean
OrderRepository orders;
@Autowired
OrderService orderService;
@Test
void placesOrderThroughTheSpringBean() {
given(catalog.findPrice("KB-100")).willReturn(Optional.of(new BigDecimal("89.00")));
given(inventory.reserve("KB-100", 2)).willReturn(true);
given(payments.charge(anyString(), any())).willReturn(new PaymentResult("TX-9001", true));
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
assertThat(orderService.placeOrder(new PlaceOrderRequest("C-42", "KB-100", 2)).total())
.isEqualByComparingTo("178.00");
}
}
8. Common Mockito Errors and How to Fix Them
8.1 NullPointerException: "this.catalog" Is Null
The @Mock fields were never filled, because nothing told JUnit to run Mockito:
java.lang.NullPointerException: Cannot invoke "com.javabytechie.shop.order.ProductCatalog.findPrice(String)" because "this.catalog" is null
How to fix it: add @ExtendWith(MockitoExtension.class) to the test class.
8.2 UnnecessaryStubbingException
A test stubbed the price lookup, but the quantity was invalid, so the service never asked for the price:
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
1. -> at your test method (YourTest.java:line)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
How to fix it: delete the unused stub. It hides what the test really depends on. If a stub in a shared @BeforeEach really is used by only some tests, move it into those tests or into a @Nested class. Mark a stub lenient() only as a last resort.
8.3 InvalidUseOfMatchersException
org.mockito.exceptions.misusing.InvalidUseOfMatchersException:
Invalid use of argument matchers!
2 matchers expected, 1 recorded:
-> at your test method (YourTest.java:line)
This exception may occur if matchers are combined with raw values:
//incorrect:
someMethod(any(), "raw String");
The stub mixed a raw value with a matcher: given(payments.charge("C-42", any())). How to fix it: wrap raw values in eq(), as in charge(eq("C-42"), any()), or use plain values for all arguments.
8.4 PotentialStubbingProblem: Argument Mismatch
The test stubbed charge("C-42", new BigDecimal("178")), but the service passed 178.00. BigDecimal.equals compares the scale too, so the stub never matched, and strict stubs reported it straight away instead of returning null:
org.mockito.exceptions.misusing.PotentialStubbingProblem:
Strict stubbing argument mismatch. Please check:
- this invocation of 'charge' method:
payments.charge("C-42", 178.00);
-> at com.javabytechie.shop.order.OrderService.placeOrder(OrderService.java:46)
- has following stubbing(s) with different arguments:
1. payments.charge("C-42", 178); stubbed with: [Returns: PaymentResult[transactionId=TX-1, approved=true]]
-> at your test method (YourTest.java:line)
How to fix it: stub with exactly the value the code produces (new BigDecimal("178.00")), or match by value with argThat(amount -> amount.compareTo(new BigDecimal("178")) == 0).
8.5 NullPointerException with @InjectMocks
@InjectMocks passes a mock for each constructor parameter it can match, and null for the rest. With no Clock mock, OrderService received null:
java.lang.NullPointerException: Cannot invoke "java.time.Clock.instant()" because "this.clock" is null
How to fix it: create the class yourself in @BeforeEach when some dependencies are real objects, as the example does. The constructor then shows every dependency, and the compiler checks them.
8.6 "Argument(s) Are Different!" on verify
A verification expected release("KB-100", 3), but the service released 2. Mockito lists the calls that did happen, which usually points straight at the mistake:
Argument(s) are different! Wanted:
inventory.release("KB-100", 3);
-> at your test method (YourTest.java:line)
Actual invocations have different arguments at position [1]:
inventory.reserve("KB-100", 2);
-> at com.javabytechie.shop.order.OrderService.placeOrder(OrderService.java:42)
inventory.release("KB-100", 2);
8.7 "Mockito Is Currently Self-Attaching"
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (.../byte-buddy-agent-1.18.15.jar)
WARNING: Dynamic loading of agents will be disallowed by default in a future release
This is only a warning today, but future JDKs will refuse to load agents at runtime. How to fix it: add the Surefire -javaagent configuration from section 4. With it, the warning disappears and all tests still pass.
9. When to Write Unit Tests (and When Not To)
| Good fit for a unit test | Better tested another way |
|---|---|
| Business rules: discounts, limits, status changes | SQL queries and mappings: @DataJpaTest or Testcontainers |
| Branching and error handling in services | JSON, validation and HTTP status codes: @WebMvcTest |
| Calculations and boundary values | Spring configuration and wiring: @SpringBootTest |
| Orchestration: which collaborator is called, and when | Thin classes that only delegate, with no logic of their own |
10. Best Practices
- Test behaviour, not implementation: assert on results and important side effects, not on every internal call.
- Use constructor injection in production code, so tests can create classes with
new. - Mock only what you don't own or can't run fast: gateways, repositories, clients. Use real records and value objects.
- Inject time as a Clock, and use
Clock.fixedin tests. - Keep Mockito's strict stubs and remove unnecessary stubs instead of making them lenient.
- Verify what must not happen with
never()andverifyNoInteractionson failure paths. - Give each test one reason to fail, and name it with
@DisplayName. - Cover boundaries with parameterized tests: just below, at and above every limit.
- Prefer AssertJ for assertions, including
assertThatThrownByfor exceptions. - Register Mockito as a Java agent in the build on JDK 21 and later.
- Save @SpringBootTest for wiring; don't start a context to test plain logic.
11. Summary
Unit testing with JUnit 5 and Mockito tests one Spring Boot class at a time, with its collaborators replaced by mocks and no Spring context at all. Add spring-boot-starter-test, annotate the test class with @ExtendWith(MockitoExtension.class), declare collaborators as @Mock, and build the class under test in @BeforeEach. Stub answers with given(...), call the real method, then check the result with AssertJ and the side effects with then(...).should(), never(), InOrder and ArgumentCaptor. Cover boundaries with parameterized tests and group scenarios with @Nested. The main recommendation: test every path through your business logic with fast unit tests, and keep slower Spring tests for the wiring and infrastructure that unit tests can't reach.
Frequently Asked Questions (FAQ)
Q1. What is the difference between @Mock and @InjectMocks?
@Mock creates a fake collaborator whose methods return defaults until you stub them. @InjectMocks creates the real class under test and passes the @Mock fields into its constructor. You mock the dependencies and inject them into the one class you are testing.
Q2. What is the difference between @Mock and @MockitoBean?
@Mock is plain Mockito and works without Spring, in fast unit tests. @MockitoBean replaces a bean inside a Spring application context, so it is used in @WebMvcTest, @DataJpaTest or @SpringBootTest. Since Spring Boot 4, @MockitoBean also replaces the removed @MockBean.
Q3. Should I use when() or given() in Mockito?
They do the same thing. given().willReturn() from BDDMockito fits the given / when / then structure of a test, while when().thenReturn() is the classic spelling. Pick one style per project and use it consistently.
Q4. Why do I get UnnecessaryStubbingException?
MockitoExtension uses strict stubs by default and fails a test when a stub is never used. That usually means the stub is leftover code or the test took a different path than you thought. Remove it, or move it to the tests that need it; use lenient() only as a last resort.
Q5. Is JUnit 5 the same as JUnit Jupiter?
Jupiter is the programming model that JUnit 5 introduced: @Test, @BeforeEach, @ParameterizedTest and the org.junit.jupiter packages. JUnit 6, which Spring Boot 4 uses, keeps the same Jupiter API, so tests written for JUnit 5 still compile and run.
Q6. Can Mockito mock final classes and records?
Yes. Since Mockito 5 the inline mock maker is the default, so final classes and methods can be mocked. Records usually don't need mocking at all: create a real instance, because they are plain data.
Q7. How do I mock a static method like Instant.now()?
Mockito.mockStatic can do it, but it is clumsy and must be closed after each test. A better design is to inject a java.time.Clock and call clock.instant(), then pass Clock.fixed(...) in tests, as the example does.
Q8. Should unit tests use @SpringBootTest?
No. @SpringBootTest starts the application context, which takes time, and tests wiring rather than logic. Use plain JUnit with Mockito for business logic, and keep @SpringBootTest for a few tests that check the application starts and the parts work together.