Chapters 3 to 5 built a catalogue and order API with rules that matter: a SKU must be unique, stock must never go below zero, an order takes stock for every line it contains. So far those rules were checked with curl against a running application and a database. This article opens Chapter 6 by checking them with unit tests: the service class runs for real, its repositories are replaced by Mockito mocks, and nothing starts, neither Spring nor a database.
The examples use Spring Boot 4.1.1 and Java 21, on an Initializr project with the web, validation, Spring Data JPA and H2 dependencies, and the test libraries Boot manages: JUnit 6, AssertJ and Mockito 5.
![]()
The first two sections set up the code and the Gradle test task; JUnit, AssertJ and Mockito then get a section each, and the last sections put them together on ProductService and OrderService.
What a unit test is in a Spring Boot application
A unit test here is one class under test, created with new, with each collaborator it depends on replaced by something the test controls: a Mockito mock for a repository, a fixed Clock for time. There is no ApplicationContext, no @Transactional proxy, no Hibernate and no database, so the test measures exactly one thing: whether the code in that class does what its rules say.
The service layer is where that pays off most. Article 21 put the business rules there: controllers translate HTTP, repositories store, and the service decides that a duplicate SKU is an error or that an order with too little stock must not be saved. A service unit test can drive every branch of those rules, including the failures that are awkward to produce through HTTP, in milliseconds.

What a unit test cannot tell you is whether the pieces work together: whether the derived query in ProductRepository is valid, whether the JSON of a response is right, whether the transaction rolls back. The next article adds those tests on top of this one, with a Spring context: @SpringBootTest, @WebMvcTest and @DataJpaTest. Testcontainers, architecture rules, contract tests and performance tests belong to the Advanced course.
The classes under test
The code is the Chapter 4 catalogue trimmed to what the tests need: no customers, no categories. It keeps the package-by-feature layout from article 21. Product is a JPA entity, and it now owns the stock rule itself, so that OrderService can use ProductRepository directly without a second copy of the check:
package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private int stock;
protected Product() {
}
public Product(String name, String sku, BigDecimal price, int stock) {
this.name = name;
this.sku = sku;
this.price = price;
this.stock = stock;
}
public void decreaseStock(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive, was " + quantity);
}
if (quantity > stock) {
throw new InsufficientStockException(sku, stock, quantity);
}
stock -= quantity;
}
// getters for id, name, sku, price and stock
}package com.example.demo.product;
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String sku, int available, int requested) {
super("Only " + available + " of " + sku + " in stock, " + requested + " requested");
}
}DuplicateSkuException and ProductNotFoundException have the same shape, with the messages SKU KB-01 already exists and Product 99 not found. All three are unchecked, and the GlobalExceptionHandler from the earlier chapters maps them to 409, 409 and 404.
package com.example.demo.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
boolean existsBySku(String sku);
}package com.example.demo.product;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository products;
public ProductService(ProductRepository products) {
this.products = products;
}
public Product findById(Long id) {
return products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product create(Product product) {
if (products.existsBySku(product.getSku())) {
throw new DuplicateSkuException(product.getSku());
}
return products.save(product);
}
}The existsBySku check gives a clear 409 for the common case; the unique constraint on the column still catches two requests that pass the check at the same moment. The order side has an entity with lines, a request record and a repository:
package com.example.demo.order;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Instant placedAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderLine> lines = new ArrayList<>();
protected Order() {
}
public Order(Instant placedAt) {
this.placedAt = placedAt;
}
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public BigDecimal total() {
return lines.stream()
.map(OrderLine::lineTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// getters for id, placedAt and lines
}package com.example.demo.order;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "order_lines")
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal unitPrice;
protected OrderLine() {
}
public OrderLine(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
this.unitPrice = product.getPrice();
}
public BigDecimal lineTotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
void setOrder(Order order) {
this.order = order;
}
// getters for product, quantity and unitPrice
}package com.example.demo.order;
public record OrderItem(Long productId, int quantity) {
}package com.example.demo.order;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {
}OrderService.placeOrder stamps the order with the time from an injected Clock, takes stock for each line and saves the order once. Article 32 declared a Clock bean in AuditingConfig for auditing; this trimmed project has no auditing, so a small ClockConfig declares it instead (in the full project, inject the one from AuditingConfig):
package com.example.demo.order;
import java.time.Clock;
import java.util.List;
import com.example.demo.product.Product;
import com.example.demo.product.ProductNotFoundException;
import com.example.demo.product.ProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final ProductRepository products;
private final OrderRepository orders;
private final Clock clock;
public OrderService(ProductRepository products, OrderRepository orders, Clock clock) {
this.products = products;
this.orders = orders;
this.clock = clock;
}
@Transactional
public Order placeOrder(List<OrderItem> items) {
Order order = new Order(clock.instant());
for (OrderItem item : items) {
Product product = products.findById(item.productId())
.orElseThrow(() -> new ProductNotFoundException(item.productId()));
product.decreaseStock(item.quantity());
order.addLine(new OrderLine(product, item.quantity()));
}
return orders.save(order);
}
}package com.example.demo.common;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ClockConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}The @Service and @Transactional annotations stay on the classes. In a unit test they do nothing, because nothing reads them: the test calls new OrderService(...), and only the Spring container would wrap that object in a transaction proxy.
Running tests with Gradle
What Spring Initializr puts in build.gradle
Initializr adds a matching test starter for each of the data-jpa, validation and webmvc starters, plus the JUnit Platform launcher, and configures the test task to run on the JUnit Platform:
dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}useJUnitPlatform() tells Gradle to discover and run tests through the JUnit Platform, which is how JUnit 6 tests are found. With that line deleted, Gradle 9.7.1 found nothing and failed the build:
> Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There are test sources present and no filters are applied, but the test task did not discover any tests to execute. This is likely due to a misconfiguration. Please check your test configuration. If this is not a misconfiguration, this error can be disabled by setting the 'failOnNoDiscoveredTests' property to false.junit-platform-launcher has no version, because Boot's dependency management supplies it. Gradle needs the launcher on the test runtime classpath: with it excluded from testRuntimeClasspath, the test task stopped before running anything with
> Failed to load JUnit Platform. Please ensure that all JUnit Platform dependencies are available on the test's runtime classpath, including the JUnit Platform launcher.In this project the line is a safeguard rather than the only source, because spring-boot-starter-test 4.1.1 depends on the launcher too (the last line of the output below); with the testRuntimeOnly line deleted, the tests still ran. None of the three starters names JUnit, AssertJ or Mockito either: each depends on spring-boot-starter-test, which brings all of them.
./gradlew -q dependencies --configuration testRuntimeClasspath | grep -E -- '--- (org.springframework.boot:spring-boot-starter-test|org.junit.jupiter:junit-jupiter|org.junit.platform:junit-platform-launcher|org.assertj:assertj-core|org.mockito:mockito-core|org.mockito:mockito-junit-jupiter)(:| ->) ?[0-9.]+$'| +--- org.springframework.boot:spring-boot-starter-test:4.1.1
| | +--- org.assertj:assertj-core:3.27.7
| | +--- org.junit.jupiter:junit-jupiter:6.0.3
| | +--- org.mockito:mockito-core:5.23.0
| | +--- org.mockito:mockito-junit-jupiter:5.23.0
| | \--- org.junit.platform:junit-platform-launcher -> 6.0.3Test classes go under src/test/java, in the same package as the class they test, which lets a test reach package-private members such as OrderLine.setOrder.
Passing and failing runs
./gradlew testWith every test in this article passing, the default output says almost nothing:
> Task :compileJava
> Task :processResources
> Task :classes
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2026-09-16T14:11:24.974+07:00 INFO 92746 --- [demo] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-09-16T14:11:24.979+07:00 INFO 92746 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2026-09-16T14:11:24.981+07:00 INFO 92746 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
> Task :test
BUILD SUCCESSFUL in 8s
4 actionable tasks: 4 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.htmlNo test is listed. The three log lines come from DemoApplicationTests, the @SpringBootTest class Initializr generates: its application context is closed by a shutdown hook when the test JVM exits. The JVM warning is caused by Mockito and is explained near the end of the article. One failing assertion changes the picture:
> Task :test
OrderLineTest > multipliesUnitPriceByQuantity() FAILED
org.opentest4j.AssertionFailedError at OrderLineTest.java:18
2026-09-16T14:12:12.662+07:00 INFO 94022 --- [demo] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-09-16T14:12:12.667+07:00 INFO 94022 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2026-09-16T14:12:12.669+07:00 INFO 94022 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
> Task :test FAILED
16 tests completed, 1 failed, 1 skipped
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///.../demo/build/reports/tests/test/index.htmlThe build fails, and Gradle names the test, the exception type and the line, but not the message. The full message, standard output and timing of every test are in the HTML report at build/reports/tests/test/index.html, with one page per test class, and in the XML files under build/test-results/test, which CI servers read.
Printing each test with testLogging
To see results in the console, configure the test task's logging:
tasks.named('test') {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed'
showStandardStreams = true
exceptionFormat = 'full'
}
}events lists which results get a line, showStandardStreams prints what tests write to System.out and System.err, and exceptionFormat = 'full' prints the failure message and stack trace. The same failing test, run on its own with --tests:
./gradlew test --tests OrderLineTest> Task :test FAILED
OrderLineTest > multipliesUnitPriceByQuantity() FAILED
org.opentest4j.AssertionFailedError:
expected: 10.0
but was: 10.00
at app//com.example.demo.order.OrderLineTest.multipliesUnitPriceByQuantity(OrderLineTest.java:18)
1 test completed, 1 failedNow the reason is on screen. The AssertJ section explains why 10.0 and 10.00 are not equal.
Running one class or one method
--tests takes a class name, a fully qualified name, a Class.method pair or a pattern with *, and can be repeated. Until the Mockito agent is configured near the end of the article, the first test class that uses Mockito also prints a STANDARD_ERROR block with a warning; the outputs below leave that block out. One method:
./gradlew test --tests 'OrderServiceTest.savesNothingWhenALineHasTooLittleStock'OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
BUILD SUCCESSFUL in 1sEvery class whose name ends in ServiceTest:
./gradlew test --tests '*ServiceTest'OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSED
BUILD SUCCESSFUL in 1sGradle skips the test task when neither the code nor the tests changed since a passing run. ./gradlew test --rerun forces it to run again.
JUnit 6 essentials
The JUnit that Boot 4.1.1 brings is JUnit 6.0.3. For code written against JUnit 5 very little changes: the annotations and packages below are the same ones. The differences this project runs into are that JUnit 6 needs Java 17 or later, that the Platform artifacts such as junit-platform-launcher now carry the same version as Jupiter (6.0.3 above, where JUnit 5 paired Jupiter 5.x with Platform 1.x), that parameterized test names quote text arguments, and that @Nested classes run in a deterministic but intentionally non-obvious order, as test methods already did.
| Annotation | Purpose |
|---|---|
@Test | Marks a test method. It may be package-private and must return void |
@DisplayName("...") | A readable name for a test class or method in reports and IDE views |
@BeforeEach / @AfterEach | Run before and after every test method, on that test's instance |
@BeforeAll / @AfterAll | Run once before and after all tests of the class; static by default |
@TestInstance(Lifecycle.PER_CLASS) | Reuse one instance for all tests of the class; @BeforeAll may then be non-static |
@Nested | An inner class grouping related tests, with its own lifecycle methods |
@ParameterizedTest | Runs the method once per set of arguments from a source annotation |
@ValueSource | One argument per invocation, from a literal array |
@CsvSource | Several arguments per invocation, from CSV lines |
@MethodSource | Arguments from a static factory method returning a Stream |
@Disabled("reason") | Skips the test class or method and records why |
@ExtendWith(...) | Registers an extension, such as MockitoExtension |
A new instance for every test method
JUnit creates a new instance of the test class for every test method. That is why fields set in one test never leak into the next, and why @BeforeAll is static: when it runs, there is no instance yet. A class that prints its identity and an instance counter shows it:
package com.example.demo.lab;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class LifecycleTest {
private static int instances = 0;
private final int number;
private int counter = 0;
LifecycleTest() {
number = ++instances;
log("constructor");
}
@BeforeAll
static void beforeAll() {
System.out.println("@BeforeAll instances so far: " + instances);
}
@BeforeEach
void beforeEach() {
log("@BeforeEach");
}
@Test
void incrementsTheCounter() {
counter++;
log("test");
}
@Test
void incrementsTheCounterAgain() {
counter++;
log("test");
}
@AfterEach
void afterEach() {
log("@AfterEach");
}
@AfterAll
static void afterAll() {
System.out.println("@AfterAll instances created: " + instances);
}
private void log(String step) {
System.out.println(step + " instance #" + number
+ " @" + Integer.toHexString(System.identityHashCode(this))
+ " counter=" + counter);
}
}./gradlew test --tests LifecycleTestLifecycleTest STANDARD_OUT
@BeforeAll instances so far: 0
constructor instance #1 @590adb41 counter=0
LifecycleTest > incrementsTheCounter() STANDARD_OUT
@BeforeEach instance #1 @590adb41 counter=0
test instance #1 @590adb41 counter=1
@AfterEach instance #1 @590adb41 counter=1
LifecycleTest > incrementsTheCounter() PASSED
LifecycleTest STANDARD_OUT
constructor instance #2 @2daf06fc counter=0
LifecycleTest > incrementsTheCounterAgain() STANDARD_OUT
@BeforeEach instance #2 @2daf06fc counter=0
test instance #2 @2daf06fc counter=1
@AfterEach instance #2 @2daf06fc counter=1
LifecycleTest > incrementsTheCounterAgain() PASSED
LifecycleTest STANDARD_OUT
@AfterAll instances created: 2Two tests, two constructor calls, two identities, and counter=1 in both tests even though each test increments the same field: the second test got a fresh object whose field started at 0.

@TestInstance(PER_CLASS) switches that off:
package com.example.demo.lab;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassLifecycleTest {
private int counter = 0;
@BeforeAll
void beforeAll() {
System.out.println("@BeforeAll @" + Integer.toHexString(System.identityHashCode(this)));
}
@Test
void incrementsTheCounter() {
counter++;
System.out.println("test @" + Integer.toHexString(System.identityHashCode(this)) + " counter=" + counter);
}
@Test
void incrementsTheCounterAgain() {
counter++;
System.out.println("test @" + Integer.toHexString(System.identityHashCode(this)) + " counter=" + counter);
}
}PerClassLifecycleTest STANDARD_OUT
@BeforeAll @f1f7db2
PerClassLifecycleTest > incrementsTheCounter() STANDARD_OUT
test @f1f7db2 counter=1
PerClassLifecycleTest > incrementsTheCounter() PASSED
PerClassLifecycleTest > incrementsTheCounterAgain() STANDARD_OUT
test @f1f7db2 counter=2
PerClassLifecycleTest > incrementsTheCounterAgain() PASSEDOne instance, a non-static @BeforeAll, and a counter that carries over. The second test now depends on the first having run, which is the coupling the default avoids. The service tests below keep the default and build their fixtures in @BeforeEach.
Grouping tests with @Nested and @DisplayName
ProductServiceTest, shown in full later, groups its tests by the method they exercise. Each @Nested class is a non-static inner class, so its tests can use the outer class's fields and @BeforeEach:
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
// @Mock field and @BeforeEach setUp()
@Nested
@DisplayName("create")
class Create {
@Test
@DisplayName("saves a product whose SKU is free")
void savesANewProduct() {
// ...
}
@Test
@DisplayName("rejects a duplicate SKU and never saves")
void rejectsADuplicateSku() {
// ...
}
}
@Nested
@DisplayName("findById")
class FindById {
@Test
@DisplayName("throws ProductNotFoundException for an unknown id")
void throwsForAnUnknownId() {
// ...
}
}
}Gradle prints the nesting and the display names:
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSEDFindById is declared after Create and ran first: that is the JUnit 6 nested-class order at work, and a reason never to let one test rely on another.
Parameterized tests: @ValueSource, @CsvSource and @MethodSource
The stock rule on Product has several boundaries, which is what a parameterized test is for. ProductTest needs no mocks at all:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import java.math.BigDecimal;
import java.util.stream.Stream;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
class ProductTest {
private static Product keyboardWithStock(int stock) {
return new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), stock);
}
@ParameterizedTest
@ValueSource(ints = {0, -1, -10})
void rejectsANonPositiveQuantity(int quantity) {
assertThatIllegalArgumentException()
.isThrownBy(() -> keyboardWithStock(10).decreaseStock(quantity))
.withMessage("Quantity must be positive, was " + quantity);
}
@ParameterizedTest(name = "stock {0} minus {1} leaves {2}")
@CsvSource({
"10, 1, 9",
"10, 10, 0",
"1, 1, 0"
})
void decreasesStock(int stock, int quantity, int remaining) {
Product keyboard = keyboardWithStock(stock);
keyboard.decreaseStock(quantity);
assertThat(keyboard.getStock()).isEqualTo(remaining);
}
@ParameterizedTest
@MethodSource("tooLargeQuantities")
void rejectsMoreThanTheStock(int stock, int quantity, String message) {
assertThatThrownBy(() -> keyboardWithStock(stock).decreaseStock(quantity))
.isInstanceOf(InsufficientStockException.class)
.hasMessage(message);
}
static Stream<Arguments> tooLargeQuantities() {
return Stream.of(
arguments(0, 1, "Only 0 of KB-01 in stock, 1 requested"),
arguments(3, 4, "Only 3 of KB-01 in stock, 4 requested"));
}
@Test
@Disabled("Restocking arrives with the purchasing feature")
void increasesStockOnRestock() {
}
}@ValueSource supplies one value per run. @CsvSource splits each line into arguments and converts them to the parameter types. @MethodSource("tooLargeQuantities") calls a static method in the same class that returns a Stream<Arguments>, which is the source to use when the arguments are objects rather than literals.
./gradlew test --tests ProductTestProductTest > rejectsMoreThanTheStock(int, int, String) > [1] stock = 0, quantity = 1, message = "Only 0 of KB-01 in stock, 1 requested" PASSED
ProductTest > rejectsMoreThanTheStock(int, int, String) > [2] stock = 3, quantity = 4, message = "Only 3 of KB-01 in stock, 4 requested" PASSED
ProductTest > increasesStockOnRestock() SKIPPED
ProductTest > rejectsANonPositiveQuantity(int) > [1] quantity = 0 PASSED
ProductTest > rejectsANonPositiveQuantity(int) > [2] quantity = -1 PASSED
ProductTest > rejectsANonPositiveQuantity(int) > [3] quantity = -10 PASSED
ProductTest > decreasesStock(int, int, int) > stock "10" minus "1" leaves "9" PASSED
ProductTest > decreasesStock(int, int, int) > stock "10" minus "10" leaves "0" PASSED
ProductTest > decreasesStock(int, int, int) > stock "1" minus "1" leaves "0" PASSEDThe default name, [{index}] {argumentSetNameOrArgumentsWithNames}, prints name = value pairs; the parameter names are available because Spring Boot's Gradle plugin compiles with -parameters. The custom name for decreasesStock shows JUnit 6's quoting: the parameters are int, but a @CsvSource value is text until it is converted, so {0} prints "10". quoteTextArguments = false turns that off:
@ParameterizedTest(name = "stock {0} minus {1} leaves {2}")
@ParameterizedTest(name = "stock {0} minus {1} leaves {2}", quoteTextArguments = false)
@CsvSource({ProductTest > decreasesStock(int, int, int) > stock 10 minus 1 leaves 9 PASSED
ProductTest > decreasesStock(int, int, int) > stock 10 minus 10 leaves 0 PASSED
ProductTest > decreasesStock(int, int, int) > stock 1 minus 1 leaves 0 PASSEDSkipping a test with @Disabled
increasesStockOnRestock above appears as SKIPPED and does not fail the build. The reason string is for the next person who reads the code: JUnit passes it to its listeners with the skip event, but in this run Gradle 9.7.1 printed only SKIPPED in the console, and the reason appeared in neither the HTML report nor the XML file. A disabled test with no reason is a test nobody will ever re-enable, so always give one.
AssertJ assertions and failure messages
AssertJ's entry point is assertThat(actual), which returns an assertion object that knows the actual value's type, so the IDE offers only the checks that make sense for it: hasSize for a list, isZero for a number, hasMessage for an exception. Every assertion below is a static import from org.assertj.core.api.Assertions.
isEqualTo and the BigDecimal scale trap
OrderLine.lineTotal() multiplies a unit price with scale 2 by a quantity. The first version of its test:
package com.example.demo.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import org.junit.jupiter.api.Test;
class OrderLineTest {
@Test
void multipliesUnitPriceByQuantity() {
Product cable = new Product("USB-C cable", "CB-02", new BigDecimal("5.00"), 50);
OrderLine line = new OrderLine(cable, 2);
assertThat(line.lineTotal()).isEqualTo(new BigDecimal("10.0"));
}
}OrderLineTest > multipliesUnitPriceByQuantity() FAILED
org.opentest4j.AssertionFailedError:
expected: 10.0
but was: 10.00
at app//com.example.demo.order.OrderLineTest.multipliesUnitPriceByQuantity(OrderLineTest.java:18)Ten is ten, and the test still fails. isEqualTo calls BigDecimal.equals, which compares the value and the scale: 5.00 × 2 is 10.00 with scale 2, and new BigDecimal("10.0") has scale 1. Money columns in this series have scale = 2, so this comes up in every test that checks a price or a total. isEqualByComparingTo uses compareTo, which ignores scale:
assertThat(line.lineTotal()).isEqualTo(new BigDecimal("10.0"));
assertThat(line.lineTotal()).isEqualByComparingTo(new BigDecimal("10.0")); OrderLineTest > multipliesUnitPriceByQuantity() PASSEDIt also accepts a string, isEqualByComparingTo("204.30"), which the order test uses. JUnit's own assertion fails the same way, assertEquals(new BigDecimal("10.0"), line.lineTotal()) reporting expected: <10.0> but was: <10.00>: for a single value the two messages carry the same information, and AssertJ's advantage shows with collections and exceptions, where it says what was found, what was missing and what was not expected.
Collections: containsExactly and extracting
extracting maps each element before the check, so a list of entities can be compared as plain values. With two functions, each element becomes a tuple:
assertThat(order.getLines())
.extracting(line -> line.getProduct().getStock(), OrderLine::getQuantity)
.containsExactly(tuple(8, 2), tuple(0, 1));containsExactly requires the same elements in the same order and nothing else; containsExactlyInAnyOrder drops the order and contains only requires the listed elements to be present. The failure message for containsExactly appears in the soft assertions run below.
Exceptions: assertThatThrownBy and assertThatExceptionOfType
Both run a lambda, fail if it throws nothing, and then check what it threw. assertThatThrownBy starts from the call:
assertThatThrownBy(() -> service.create(copy))
.isInstanceOf(DuplicateSkuException.class)
.hasMessage("SKU KB-01 already exists");assertThatExceptionOfType starts from the type, and has shortcuts such as assertThatIllegalArgumentException() used in ProductTest:
assertThatExceptionOfType(ProductNotFoundException.class)
.isThrownBy(() -> service.findById(99L))
.withMessage("Product 99 not found");The code after either one keeps running, so a test can go on to check that nothing was saved. That is the difference from wrapping the call in try/catch with a fail() in the try, which is easy to get wrong.
Soft assertions: several failures in one run
A normal assertion stops the test at the first failure. When a test checks several properties of one result, you fix one, re-run, and meet the next. assertSoftly collects the failures and reports them together:
package com.example.demo.lab;
import static org.assertj.core.api.SoftAssertions.assertSoftly;
import java.math.BigDecimal;
import java.time.Instant;
import com.example.demo.order.Order;
import com.example.demo.order.OrderLine;
import com.example.demo.product.Product;
import org.junit.jupiter.api.Test;
class SoftAssertionsTest {
@Test
void checksTheWholeOrder() {
Product keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10);
Order order = new Order(Instant.parse("2026-11-05T09:00:00Z"));
order.addLine(new OrderLine(keyboard, 2));
assertSoftly(softly -> {
softly.assertThat(order.getPlacedAt()).isEqualTo("2026-11-05T10:00:00Z");
softly.assertThat(order.getLines()).extracting(OrderLine::getQuantity).containsExactly(3);
softly.assertThat(order.total()).isEqualByComparingTo("179.80");
softly.assertThat(order.getLines()).hasSize(2);
});
}
}Three of the four expectations are wrong on purpose:
SoftAssertionsTest > checksTheWholeOrder() FAILED
org.assertj.core.error.AssertJMultipleFailuresError:
Multiple Failures (3 failures)
-- failure 1 --
expected: 2026-11-05T10:00:00Z
but was: 2026-11-05T09:00:00Z
at SoftAssertionsTest.lambda$checksTheWholeOrder$0(SoftAssertionsTest.java:23)
-- failure 2 --
Expecting actual:
[2]
to contain exactly (and in same order):
[3]
but some elements were not found:
[3]
and others were not expected:
[2]
at SoftAssertionsTest.lambda$checksTheWholeOrder$0(SoftAssertionsTest.java:24)
-- failure 3 --
Expected size: 2 but was: 1 in:
[com.example.demo.order.OrderLine@177c41d7]
at SoftAssertionsTest.lambda$checksTheWholeOrder$0(SoftAssertionsTest.java:26)
at app//com.example.demo.lab.SoftAssertionsTest.checksTheWholeOrder(SoftAssertionsTest.java:22)All three failures, each with its line, and the total on line 25, which was right, is absent. isEqualTo("2026-11-05T10:00:00Z") on an Instant parses the string, which keeps time assertions readable. OrderLine@177c41d7 is Java's default toString(), because OrderLine does not override it.
Mockito for the service layer
@Mock, MockitoExtension and default answers
@ExtendWith(MockitoExtension.class) makes Mockito create a mock for each @Mock field before every test, check the stubbings after it, and start the next test with fresh mocks, so no state leaks between tests. A mock is an object of the field's type whose methods do nothing and return a default:
package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class DefaultAnswersTest {
@Mock
private ProductRepository products;
@Test
void unstubbedCallsReturnDefaults() {
System.out.println("existsBySku(\"KB-01\") -> " + products.existsBySku("KB-01"));
System.out.println("findById(1L) -> " + products.findById(1L));
System.out.println("findAll() -> " + products.findAll());
System.out.println("count() -> " + products.count());
System.out.println("save(product) -> " + products.save(new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5)));
System.out.println("mock -> " + products);
System.out.println("mock class -> " + products.getClass().getName());
}
}DefaultAnswersTest > unstubbedCallsReturnDefaults() STANDARD_OUT
existsBySku("KB-01") -> false
findById(1L) -> Optional.empty
findAll() -> []
count() -> 0
save(product) -> null
mock -> products
mock class -> com.example.demo.product.ProductRepository$MockitoMock$CVaIiiSQ
DefaultAnswersTest > unstubbedCallsReturnDefaults() PASSEDfalse, 0 and null for primitives and plain objects, but an empty Optional and an empty list rather than null, so an unstubbed findById makes the service take its "not found" branch instead of throwing a NullPointerException. The mock is named after the field, which is how it appears in Mockito's error messages. A mock records every call made on it; that record is what verify reads.
Constructor injection instead of @InjectMocks
Mockito can also build the class under test. @InjectMocks picks the largest constructor and passes the @Mock fields that match its parameter types:
package com.example.demo.order;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderServiceInjectMocksTest {
@Mock
private ProductRepository products;
@Mock
private OrderRepository orders;
@InjectMocks
private OrderService service;
@Test
void placesAnOrder() {
given(products.findById(1L))
.willReturn(Optional.of(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10)));
service.placeOrder(List.of(new OrderItem(1L, 2)));
then(orders).should().save(any(Order.class));
}
}OrderService has a third constructor parameter, the Clock, and the test declares no mock for it:
OrderServiceInjectMocksTest > placesAnOrder() FAILED
java.lang.NullPointerException: Cannot invoke "java.time.Clock.instant()" because "this.clock" is null
at com.example.demo.order.OrderService.placeOrder(OrderService.java:28)
at com.example.demo.order.OrderServiceInjectMocksTest.placesAnOrder(OrderServiceInjectMocksTest.java:37)Mockito built the service anyway and passed null for the parameter it could not match, so the mistake surfaced as a NullPointerException inside the service instead of at the point where the test was set up. Calling the constructor yourself in @BeforeEach turns the same mistake into a compile error the moment the service gains a dependency, which is why the tests in this article do that. It is the same reason article 7 recommended constructor injection for the beans themselves.
Stubbing and verifying: when, thenReturn, thenThrow, verify
A stubbing tells a mock what to answer for a call with particular arguments. A verification asks afterwards whether a call happened. The classic Mockito API, on ProductService.create:
package com.example.demo.product;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
@ExtendWith(MockitoExtension.class)
class ProductServiceMockitoTest {
@Mock
private ProductRepository products;
private ProductService service;
@BeforeEach
void setUp() {
service = new ProductService(products);
}
@Test
void savesANewProduct() {
Product hub = new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5);
when(products.existsBySku("HUB-07")).thenReturn(false);
when(products.save(hub)).thenReturn(hub);
Product created = service.create(hub);
assertThat(created).isSameAs(hub);
verify(products).existsBySku("HUB-07");
verify(products).save(hub);
verifyNoMoreInteractions(products);
}
@Test
void doesNotSaveADuplicateSku() {
Product copy = new Product("Compact keyboard", "KB-01", new BigDecimal("59.00"), 5);
when(products.existsBySku("KB-01")).thenReturn(true);
assertThatThrownBy(() -> service.create(copy)).isInstanceOf(DuplicateSkuException.class);
verify(products, never()).save(any());
}
@Test
void passesAConstraintViolationThrough() {
Product hub = new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5);
when(products.existsBySku("HUB-07")).thenReturn(false);
when(products.save(hub)).thenThrow(new DataIntegrityViolationException("uk_products_sku"));
assertThatThrownBy(() -> service.create(hub))
.isInstanceOf(DataIntegrityViolationException.class)
.hasMessage("uk_products_sku");
}
}ProductServiceMockitoTest > doesNotSaveADuplicateSku() PASSED
ProductServiceMockitoTest > passesAConstraintViolationThrough() PASSED
ProductServiceMockitoTest > savesANewProduct() PASSEDwhen(mock.call(args)).thenReturn(value)records an answer. The arguments are matched withequals, or with matchers such asany(); if one argument uses a matcher, all of them must.thenThrowmakes the call throw. The third test simulates the race theuniqueconstraint exists for: the check passes, thensavefails, and the service must let the exception through for the advice to map to 409.verify(mock).call(args)checks the call happened exactly once;verify(mock, never())checks it did not happen,times(n)andatLeastOnce()cover the rest.verifyNoMoreInteractions(mock)fails if the mock received a call noverifyhas accounted for. Use it sparingly: it turns every harmless extra call into a failure.
Under MockitoExtension, a call that matched a stubbing counts as verified, so verifyNoMoreInteractions only reports calls you neither stubbed nor verified. The same duplicate-SKU test with verifyNoMoreInteractions added, in a class that switches the strictness off:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ProductServiceLenientVerifyTest {
@Mock
private ProductRepository products;
@Test
void doesNotSaveADuplicateSku() {
Product copy = new Product("Compact keyboard", "KB-01", new BigDecimal("59.00"), 5);
when(products.existsBySku("KB-01")).thenReturn(true);
assertThatThrownBy(() -> new ProductService(products).create(copy)).isInstanceOf(DuplicateSkuException.class);
verify(products, never()).save(any());
verifyNoMoreInteractions(products);
}
}ProductServiceLenientVerifyTest > doesNotSaveADuplicateSku() FAILED
org.mockito.exceptions.verification.NoInteractionsWanted:
No interactions wanted here:
-> at com.example.demo.product.ProductServiceLenientVerifyTest.doesNotSaveADuplicateSku(ProductServiceLenientVerifyTest.java:34)
But found this interaction on mock 'products':
-> at com.example.demo.product.ProductService.create(ProductService.java:22)
Actually, above is the only interaction with this mock.
at app//com.example.demo.product.ProductServiceLenientVerifyTest.doesNotSaveADuplicateSku(ProductServiceLenientVerifyTest.java:34)The unverified interaction is the stubbed existsBySku call on line 22 of ProductService. With the default strictness, the same assertion in ProductServiceTest below passes.
BDD style: given, willReturn, then
BDDMockito offers the same operations under names that follow a given / when / then layout, so that the stubbing reads as "given" and the verification as "then":
Classic Mockito | BDDMockito |
|---|---|
when(mock.call()).thenReturn(value) | given(mock.call()).willReturn(value) |
when(mock.call()).thenThrow(exception) | given(mock.call()).willThrow(exception) |
when(mock.call()).thenAnswer(answer) | given(mock.call()).willAnswer(answer) |
verify(mock).call() | then(mock).should().call() |
verify(mock, never()).call() | then(mock).should(never()).call() |
verifyNoMoreInteractions(mock) | then(mock).shouldHaveNoMoreInteractions() |
The behaviour is identical; pick one style per code base. The tests from here on use BDDMockito.
ArgumentCaptor: asserting on the object passed to save
OrderService.placeOrder creates the Order inside the method and hands it to orders.save. The test never holds a reference to that object, so it cannot compare it with equals. An ArgumentCaptor captures the argument during verification:
then(orders).should().save(savedOrder.capture());
Order order = savedOrder.getValue();savedOrder is a field annotated with @Captor, which MockitoExtension initializes like a mock. After getValue() the test has the exact object the service built and can assert on its time, its lines and its total. The products are a different case: the test creates them and returns them from the stubbed findById, so it can read their stock directly.
![Four ordered steps: @Mock creates a stand-in with default answers; given(products.findById(1L)).willReturn(Optional.of(keyboard)) records an answer; the service calls products.findById(1L) and receives Optional[keyboard] while orders.save(order) is recorded; then(orders).should().save(savedOrder.capture()) checks the Order the service built; failure branches show UnnecessaryStubbingException for an unused stub and PotentialStubbingProblem for findById(3L) stubbed but findById(2L) called](/images/blog/sb-mockito-stub-verify-flow.en.webp)
Strict stubs: UnnecessaryStubbingException and PotentialStubbingProblem
MockitoExtension runs with strict stubs by default, and it turns two kinds of test bugs into failures. The first is a stubbing nothing uses. A shared stub in setUp is the usual way to get one, for example making save return its argument for every test:
@BeforeEach
void setUp() {
service = new OrderService(products, orders, Clock.fixed(NOW, ZoneOffset.UTC));
keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10);
mouse = new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 1);
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
}OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() FAILED
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 com.example.demo.order.OrderServiceTest.setUp(OrderServiceTest.java:53)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
at app//org.mockito.junit.jupiter.MockitoExtension.lambda$afterEach$2(MockitoExtension.java:200)
at java.base@21.0.6/java.util.Optional.ifPresent(Optional.java:178)
at app//org.mockito.junit.jupiter.MockitoExtension.afterEach(MockitoExtension.java:198)
at java.base@21.0.6/java.util.ArrayList.forEach(ArrayList.java:1596)
at java.base@21.0.6/java.util.ArrayList.forEach(ArrayList.java:1596)The test whose own assertions all passed is the one that failed: the insufficient-stock path never reaches save, and the check runs in MockitoExtension.afterEach. The failure is useful, because a stub no code path uses often means the test is not exercising the path its author thought. lenient() marks one stubbing as allowed to go unused:
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
lenient().when(orders.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSEDlenient() is a static method of org.mockito.Mockito and returns a stubber that offers when(...) but no given(...), so that one line uses the classic names. The better fix is usually to move the stub into the test that needs it; the final OrderServiceTest needs no stub on save at all.
The second kind is a stubbing called with different arguments. Here the test stubs product 3 while the order asks for product 2:
@Test
void decrementsStockAndSavesTheOrder() {
given(products.findById(1L)).willReturn(Optional.of(keyboard));
given(products.findById(2L)).willReturn(Optional.of(mouse));
given(products.findById(3L)).willReturn(Optional.of(mouse));
service.placeOrder(List.of(new OrderItem(1L, 2), new OrderItem(2L, 1)));OrderServiceTest > decrementsStockAndSavesTheOrder() FAILED
org.mockito.exceptions.misusing.PotentialStubbingProblem:
Strict stubbing argument mismatch. Please check:
- this invocation of 'findById' method:
products.findById(2L);
-> at com.example.demo.order.OrderService.placeOrder(OrderService.java:30)
- has following stubbing(s) with different arguments:
1. products.findById(3L); stubbed with: [Returns: Optional[com.example.demo.product.Product@f9f3928]]
-> at com.example.demo.order.OrderServiceTest.decrementsStockAndSavesTheOrder(OrderServiceTest.java:58)
Typically, stubbing argument mismatch indicates user mistake when writing tests.
Mockito fails early so that you can debug potential problem easily.
However, there are legit scenarios when this exception generates false negative signal:
- stubbing the same method multiple times using 'given().will()' or 'when().then()' API
Please use 'will().given()' or 'doReturn().when()' API for stubbing.
- stubbed method is intentionally invoked with different arguments by code under test
Please use default or 'silent' JUnit Rule (equivalent of Strictness.LENIENT).
For more information see javadoc for PotentialStubbingProblem class.
at app//com.example.demo.order.OrderService.placeOrder(OrderService.java:30)
at app//com.example.demo.order.OrderServiceTest.decrementsStockAndSavesTheOrder(OrderServiceTest.java:60)Run with @MockitoSettings(strictness = Strictness.LENIENT), the same mistake failed with ProductNotFoundException: Product 2 not found at OrderService.java:31: the unmatched call returned the default Optional.empty, and the failure pointed at the service instead of at the test. Strict stubs throw at the call and name both the invocation and the stubbing that almost matched.
Unit tests for ProductService and OrderService
ProductServiceTest: a duplicate SKU is never saved
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import java.math.BigDecimal;
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.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository products;
private ProductService service;
@BeforeEach
void setUp() {
service = new ProductService(products);
}
@Nested
@DisplayName("create")
class Create {
@Test
@DisplayName("saves a product whose SKU is free")
void savesANewProduct() {
Product hub = new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5);
given(products.existsBySku("HUB-07")).willReturn(false);
given(products.save(hub)).willReturn(hub);
assertThat(service.create(hub)).isSameAs(hub);
}
@Test
@DisplayName("rejects a duplicate SKU and never saves")
void rejectsADuplicateSku() {
Product copy = new Product("Compact keyboard", "KB-01", new BigDecimal("59.00"), 5);
given(products.existsBySku("KB-01")).willReturn(true);
assertThatThrownBy(() -> service.create(copy))
.isInstanceOf(DuplicateSkuException.class)
.hasMessage("SKU KB-01 already exists");
then(products).should(never()).save(any());
then(products).shouldHaveNoMoreInteractions();
}
}
@Nested
@DisplayName("findById")
class FindById {
@Test
@DisplayName("throws ProductNotFoundException for an unknown id")
void throwsForAnUnknownId() {
given(products.findById(99L)).willReturn(Optional.empty());
assertThatExceptionOfType(ProductNotFoundException.class)
.isThrownBy(() -> service.findById(99L))
.withMessage("Product 99 not found");
}
}
}The duplicate-SKU test checks the rule from both sides: the exception and its message, then that save was never called and that nothing else touched the repository. An assertion on the exception alone would still pass if a future change saved the product first and threw afterwards. The @Mock field of the outer class is initialized for the @Nested tests as well, which is why the stubbings inside Create and FindById work.
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSEDOrderServiceTest: stock, the saved order and the insufficient-stock path
package com.example.demo.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import com.example.demo.product.InsufficientStockException;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
private static final Instant NOW = Instant.parse("2026-11-05T09:00:00Z");
@Mock
private ProductRepository products;
@Mock
private OrderRepository orders;
@Captor
private ArgumentCaptor<Order> savedOrder;
private OrderService service;
private Product keyboard;
private Product mouse;
@BeforeEach
void setUp() {
service = new OrderService(products, orders, Clock.fixed(NOW, ZoneOffset.UTC));
keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10);
mouse = new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 1);
}
@Test
void decrementsStockAndSavesTheOrder() {
given(products.findById(1L)).willReturn(Optional.of(keyboard));
given(products.findById(2L)).willReturn(Optional.of(mouse));
service.placeOrder(List.of(new OrderItem(1L, 2), new OrderItem(2L, 1)));
then(orders).should().save(savedOrder.capture());
Order order = savedOrder.getValue();
assertThat(order.getPlacedAt()).isEqualTo("2026-11-05T09:00:00Z");
assertThat(order.getLines())
.extracting(line -> line.getProduct().getStock(), OrderLine::getQuantity)
.containsExactly(tuple(8, 2), tuple(0, 1));
assertThat(order.total()).isEqualByComparingTo("204.30");
}
@Test
void savesNothingWhenALineHasTooLittleStock() {
given(products.findById(1L)).willReturn(Optional.of(keyboard));
given(products.findById(2L)).willReturn(Optional.of(mouse));
assertThatThrownBy(() -> service.placeOrder(List.of(new OrderItem(1L, 2), new OrderItem(2L, 3))))
.isInstanceOf(InsufficientStockException.class)
.hasMessage("Only 1 of MS-01 in stock, 3 requested");
then(orders).should(never()).save(any());
assertThat(keyboard.getStock()).isEqualTo(8);
}
}The happy path does not stub save, so the mock returns null and the test ignores the return value; what it checks is what the service handed to the repository. The captured order carries the fixed time, one line per item with the keyboard's stock taken from 10 to 8 and the mouse's from 1 to 0, and a total of 2 × 89.90 + 24.50.
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSEDThe last assertion of the failing path is worth reading twice. The keyboard line was processed before the mouse line failed, so the keyboard object in memory is at 8. In the application nothing is lost, because placeOrder is @Transactional and article 30 showed the rollback on InsufficientStockException. In this test there is no transaction, and the unit test can only prove that save was never called. Whether the database really keeps its 10 is a question for a test with a real transaction and a real database, which is where the next article goes.
Fixed time with an injected Clock
A service that calls Instant.now() produces a different value on every run, and a test can only check that the time is "recent". Because OrderService asks for a Clock, the test passes one that always returns the same instant:
private static final Instant NOW = Instant.parse("2026-11-05T09:00:00Z");
@BeforeEach
void setUp() {
service = new OrderService(products, orders, Clock.fixed(NOW, ZoneOffset.UTC));and asserts the exact value, assertThat(order.getPlacedAt()).isEqualTo("2026-11-05T09:00:00Z"). Clock.fixed is a real Clock from the JDK, not a mock: there is nothing to stub, and it answers instant(), millis() and getZone() consistently. In production the ClockConfig bean supplies Clock.systemUTC(). For time that must move during a test, such as an expiry, Clock.offset(clock, Duration.ofMinutes(15)) returns a clock shifted from the first.
What not to mock
A mock replaces behaviour you do not want to run in this test. Anything else is better real:
- The class under test. A partial mock or spy of
OrderServicetests Mockito's stubbing instead of your code. - Value objects, records and DTOs.
OrderItem,BigDecimal,Instant, a request record: construct them. They have no dependencies andequalsworks on them. - Entities and their rules.
Productis real inOrderServiceTest, sodecreaseStockand its exception run for real inside the service test. MockingProductwould mean re-stating the stock rule in stubbings. - Types with a ready-made test double.
Clock.fixedfor time, a realArrayListinstead of a mockedList. - Your repositories' queries. Stubbing
existsBySkuchecks the service's reaction totrueandfalse; it says nothing about whether the query itself is right. That needs a database.
What is left is the boundary you own: the repository interfaces, and interfaces you define around external systems such as a payment client. Article 21 showed that Mockito 5.23.0 mocks a concrete class such as ProductService just as well, so mocking a class is possible; the reason to prefer mocking your own boundary interfaces is that they change when your code changes, while a third-party class can change underneath the stubbings.
The Mockito self-attaching warning on JDK 21
The first time Mockito initializes in the test JVM, it prints this to standard error. It is visible with showStandardStreams = true, and otherwise sits only in the report, under whichever test class ran first and touched Mockito; in this run that was the generated DemoApplicationTests:
DemoApplicationTests > contextLoads() STANDARD_ERROR
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.11.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future releaseMockito 5's default inline mock maker rewrites classes in place through the JVM's instrumentation API, and without an agent on the command line it gets that API by attaching Byte Buddy's agent to the running JVM. Since JDK 21 (JEP 451) the JVM warns when an agent is loaded that way, and the last warning line says a future release will disallow it by default; Mockito's own message says the same about self-attaching. Mockito's documentation recommends loading mockito-core as a -javaagent when the test JVM starts:
configurations {
mockitoAgent
}
dependencies {
// the dependencies Initializr generated, unchanged
mockitoAgent('org.mockito:mockito-core') {
transitive = false
}
}
tasks.named('test') {
useJUnitPlatform()
jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
testLogging {
events 'passed', 'skipped', 'failed'
showStandardStreams = true
exceptionFormat = 'full'
}
}<properties>
<java.version>21</java.version>
<argLine/>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
</plugins>
</build>In Gradle, the mockitoAgent configuration holds just the mockito-core jar, without a version because Boot's dependency management supplies it, and asPath turns it into the path for -javaagent:
./gradlew -q dependencies --configuration mockitoAgentmockitoAgent
\--- org.mockito:mockito-core -> 5.23.0In Maven, the properties goal of maven-dependency-plugin defines a property named after each dependency, ${org.mockito:mockito-core:jar}, holding its path. The empty <argLine/> property is not decoration. Without it, on this Boot 4.1.1 project, @{argLine} has nothing to replace, Surefire passes it to java literally, and the build fails before a single test runs (paths shortened):
[ERROR] The forked VM terminated without properly saying goodbye. VM crash or System.exit called?
[ERROR] Command was /bin/sh -c cd '.../demo-mvn' && '.../bin/java' '@{argLine}' '-javaagent:.../mockito-core/5.23.0/mockito-core-5.23.0.jar' '-jar' ...With the property added, ./mvnw test ran 16 tests with 1 skipped and printed no Mockito warning. The Gradle build, with the agent configured and Spring's startup log trimmed:
> Task :testClasses UP-TO-DATE
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
DemoApplicationTests > contextLoads() PASSED
OrderLineTest > multipliesUnitPriceByQuantity() PASSED
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSEDThe STANDARD_ERROR block with the Mockito message and the four JDK warnings is gone. One JVM line remains. It is not about how the agent was loaded: a run of OrderLineTest alone, which creates no mock, printed it neither with nor without the agent. It appears once Mockito's inline mock maker initializes and appends its helper classes to the bootstrap class path, after which the JVM's class data sharing covers only the classes of the boot loader. It is informational, and every test passed.
How fast are unit tests?
The Gradle report shows the time of every class and every test. For the two service test classes, run on their own with the agent configured, best of three runs (indicative numbers):
./gradlew test --rerun --tests '*ServiceTest'| Test | Time |
|---|---|
OrderServiceTest (2 tests) | 0.219 s |
decrementsStockAndSavesTheOrder(), first in the JVM | 0.212 s |
savesNothingWhenALineHasTooLittleStock() | 0.002 s |
ProductServiceTest (3 tests, two @Nested classes) | 0.005 s |
Almost all of the 0.219 s is one-time setup paid by the first test in the JVM: creating the first mocks and loading classes. Every test after it takes one to three milliseconds. In a full run, where the generated DemoApplicationTests goes first, the same class took 0.134 s, because part of that setup had already happened, while DemoApplicationTests itself took 1.559 s at best, nearly all of it starting the application context for a test method that took 0.04 s. That is the baseline for the next article, where the tests start Spring on purpose.
FAQ
What changes for my tests when moving from JUnit 5 to JUnit 6?
For most test code, nothing: the org.junit.jupiter.api annotations, assertions and extensions are the same. JUnit 6 needs Java 17 or later, the Platform artifacts such as junit-platform-launcher now share Jupiter's version number, text arguments in parameterized test names are quoted (switch it off with quoteTextArguments = false), @Nested classes run in a deterministic but intentionally non-obvious order, and the CSV sources are parsed by FastCSV. Spring Boot 4.1.1 manages all of it, so a project generated by Initializr needs no version changes.
Should I use @InjectMocks or call the constructor?
Call the constructor in @BeforeEach. @InjectMocks passes null for any constructor parameter without a matching @Mock: OrderService built that way failed later with Cannot invoke "java.time.Clock.instant()" because "this.clock" is null. A constructor call stops compiling when the service gains a dependency, which is the earliest possible warning.
Why does Mockito throw UnnecessaryStubbingException?
Because MockitoExtension uses strict stubs and a stubbing in the test, or in its @BeforeEach, was never called. It is reported after the test, so the test fails even when its own assertions passed. Remove the stub, move it into the tests that use it, or mark that one stubbing with lenient() when it is shared on purpose.
How do I compare BigDecimal values in AssertJ?
With isEqualByComparingTo. isEqualTo uses BigDecimal.equals, which also compares scale, so 10.00 is not equal to 10.0 and the test fails with expected: 10.0 but was: 10.00. isEqualByComparingTo(new BigDecimal("10.0")) or isEqualByComparingTo("10.0") compares the numeric value only.
Do unit tests need @SpringBootTest?
No, and they should not use it. A service unit test builds the service with new and mocks its repositories, so there is no context to start: ProductServiceTest ran its three tests in 5 ms, while the generated @SpringBootTest class needed 1.559 s to start the context. Spring-backed tests are for what a unit test cannot see, such as mappings, queries and transactions.
How do I remove the "Mockito is currently self-attaching" warning?
Load mockito-core as a Java agent when the test JVM starts. In Gradle, add a mockitoAgent configuration with mockito-core (non-transitive) and jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}" on the test task. In Maven, use the properties goal of maven-dependency-plugin and @{argLine} -javaagent:${org.mockito:mockito-core:jar} in Surefire's argLine, and add an empty <argLine/> property or the forked JVM will not start.
Conclusion
A unit test of a service is the service created with new, its repositories replaced by Mockito mocks and its time by Clock.fixed, with no Spring context and no database. Gradle runs it through useJUnitPlatform(), testLogging puts the results and failure messages in the console, and --tests narrows a run to a class or a method. JUnit 6 creates a new test instance for every method, which the printed identities showed, groups tests with @Nested, and runs parameterized tests whose names now quote text arguments.
AssertJ's messages were most useful exactly where tests go wrong: isEqualTo on BigDecimal compares scale, and assertSoftly reported three failures in one run. Mockito's MockitoExtension added strict stubs, which failed a test for an unused stubbing and threw at a call whose arguments did not match, while ArgumentCaptor gave access to the Order the service built. The tests for the real rules confirmed that a duplicate SKU is never saved and that an order with too little stock saves nothing, in a few milliseconds each, once Mockito was loaded as an agent and its warning gone.
What these tests cannot show is whether the pieces fit together. The next article tests with Spring: @SpringBootTest for the whole application, @WebMvcTest with MockMvc and MockMvcTester for controllers, and @DataJpaTest for repositories against a real database.