Command Palette

Search for a command to run...

[Spring Boot Basics] Unit Testing in Spring Boot: JUnit 6, AssertJ and Mockito for the Service Layer

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.

A class under test with a green check, wired to two dashed @Mock stand-ins

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.

OrderServiceTest structure: OrderService is real, ProductRepository and OrderRepository are @Mock, Clock is Clock.fixed; Product, Order, OrderLine and OrderItem are real objects; Spring ApplicationContext, @Transactional proxy, Hibernate and the database are not part of the run; measured 0.219 s for 2 tests

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:

src/main/java/com/example/demo/product/Product.java
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
}
src/main/java/com/example/demo/product/InsufficientStockException.java
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.

src/main/java/com/example/demo/product/ProductRepository.java
package com.example.demo.product;
 
import org.springframework.data.jpa.repository.JpaRepository;
 
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    boolean existsBySku(String sku);
}
src/main/java/com/example/demo/product/ProductService.java
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:

src/main/java/com/example/demo/order/Order.java
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
}
src/main/java/com/example/demo/order/OrderLine.java
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
}
src/main/java/com/example/demo/order/OrderItem.java
package com.example.demo.order;
 
public record OrderItem(Long productId, int quantity) {
}
src/main/java/com/example/demo/order/OrderRepository.java
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):

src/main/java/com/example/demo/order/OrderService.java
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);
    }
}
src/main/java/com/example/demo/common/ClockConfig.java
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:

build.gradle
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:

Text
> 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

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

Bash
./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.]+$'
Text
|    +--- 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.3

Test 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

Bash
./gradlew test

With every test in this article passing, the default output says almost nothing:

Text
> 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.html

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

Text
> 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.html

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

build.gradle
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:

Bash
./gradlew test --tests OrderLineTest
Text
> 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 failed

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

Bash
./gradlew test --tests 'OrderServiceTest.savesNothingWhenALineHasTooLittleStock'
Text
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
 
BUILD SUCCESSFUL in 1s

Every class whose name ends in ServiceTest:

Bash
./gradlew test --tests '*ServiceTest'
Text
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 1s

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

AnnotationPurpose
@TestMarks 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 / @AfterEachRun before and after every test method, on that test's instance
@BeforeAll / @AfterAllRun 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
@NestedAn inner class grouping related tests, with its own lifecycle methods
@ParameterizedTestRuns the method once per set of arguments from a source annotation
@ValueSourceOne argument per invocation, from a literal array
@CsvSourceSeveral arguments per invocation, from CSV lines
@MethodSourceArguments 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:

src/test/java/com/example/demo/lab/LifecycleTest.java
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);
    }
}
Bash
./gradlew test --tests LifecycleTest
Text
LifecycleTest 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: 2

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

Trace of LifecycleTest over time: @BeforeAll with 0 instances, then instance #1 @590adb41 runs constructor, @BeforeEach, incrementsTheCounter() with counter=1 and @AfterEach, then instance #2 @2daf06fc runs the same steps for incrementsTheCounterAgain() with counter=1, then @AfterAll with 2 instances created; a PER_CLASS box shows one instance @f1f7db2 with counter 1 then 2

@TestInstance(PER_CLASS) switches that off:

src/test/java/com/example/demo/lab/PerClassLifecycleTest.java
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);
    }
}
Text
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() PASSED

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

src/test/java/com/example/demo/product/ProductServiceTest.java
@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:

Text
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

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

src/test/java/com/example/demo/product/ProductTest.java
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.

Bash
./gradlew test --tests ProductTest
Text
ProductTest > 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" PASSED

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

src/test/java/com/example/demo/product/ProductTest.java
    @ParameterizedTest(name = "stock {0} minus {1} leaves {2}") 
    @ParameterizedTest(name = "stock {0} minus {1} leaves {2}", quoteTextArguments = false) 
    @CsvSource({
Text
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 PASSED

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

src/test/java/com/example/demo/order/OrderLineTest.java
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"));
    }
}
Text
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:

src/test/java/com/example/demo/order/OrderLineTest.java
        assertThat(line.lineTotal()).isEqualTo(new BigDecimal("10.0")); 
        assertThat(line.lineTotal()).isEqualByComparingTo(new BigDecimal("10.0")); 
Text
OrderLineTest > multipliesUnitPriceByQuantity() PASSED

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

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

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

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

src/test/java/com/example/demo/lab/SoftAssertionsTest.java
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:

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

src/test/java/com/example/demo/lab/DefaultAnswersTest.java
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());
    }
}
Text
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() PASSED

false, 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:

src/test/java/com/example/demo/order/OrderServiceInjectMocksTest.java
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:

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

src/test/java/com/example/demo/product/ProductServiceMockitoTest.java
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");
    }
}
Text
ProductServiceMockitoTest > doesNotSaveADuplicateSku() PASSED
 
ProductServiceMockitoTest > passesAConstraintViolationThrough() PASSED
 
ProductServiceMockitoTest > savesANewProduct() PASSED
  • when(mock.call(args)).thenReturn(value) records an answer. The arguments are matched with equals, or with matchers such as any(); if one argument uses a matcher, all of them must.
  • thenThrow makes the call throw. The third test simulates the race the unique constraint exists for: the check passes, then save fails, 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) and atLeastOnce() cover the rest.
  • verifyNoMoreInteractions(mock) fails if the mock received a call no verify has 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:

src/test/java/com/example/demo/product/ProductServiceLenientVerifyTest.java
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);
    }
}
Text
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 MockitoBDDMockito
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:

Java
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

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:

src/test/java/com/example/demo/order/OrderServiceTest.java
    @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)); 
    }
Text
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:

src/test/java/com/example/demo/order/OrderServiceTest.java
        given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); 
        lenient().when(orders.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); 
Text
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
 
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED

lenient() 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:

src/test/java/com/example/demo/order/OrderServiceTest.java
    @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)));
Text
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

src/test/java/com/example/demo/product/ProductServiceTest.java
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.

Text
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

OrderServiceTest: stock, the saved order and the insufficient-stock path

src/test/java/com/example/demo/order/OrderServiceTest.java
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.

Text
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
 
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED

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

src/test/java/com/example/demo/order/OrderServiceTest.java
    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 OrderService tests Mockito's stubbing instead of your code.
  • Value objects, records and DTOs. OrderItem, BigDecimal, Instant, a request record: construct them. They have no dependencies and equals works on them.
  • Entities and their rules. Product is real in OrderServiceTest, so decreaseStock and its exception run for real inside the service test. Mocking Product would mean re-stating the stock rule in stubbings.
  • Types with a ready-made test double. Clock.fixed for time, a real ArrayList instead of a mocked List.
  • Your repositories' queries. Stubbing existsBySku checks the service's reaction to true and false; 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:

Text
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 release

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

build.gradle
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'
    }
}

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:

Bash
./gradlew -q dependencies --configuration mockitoAgent
Text
mockitoAgent
\--- org.mockito:mockito-core -> 5.23.0

In 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):

Text
[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:

Text
> 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 PASSED

The 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):

Bash
./gradlew test --rerun --tests '*ServiceTest'
TestTime
OrderServiceTest (2 tests)0.219 s
decrementsStockAndSavesTheOrder(), first in the JVM0.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.

Related Posts

[Spring Boot Basics] JPA Auditing in Spring Boot: @CreatedDate, @LastModifiedDate and @CreatedBy

Spring Data JPA auditing on Spring Boot 4.1.1 with PostgreSQL: @EnableJpaAuditing, AuditingEntityListener and a @MappedSuperclass base class, a Flyway migration adding NOT NULL audit columns to a table with rows, the silent nulls without the annotation or the listener, Instant vs LocalDateTime vs OffsetDateTime and what timestamptz stores, when @LastModifiedDate moves and what modifyOnCreate changes, the detached save() that writes null into created_at and @Column(updatable = false), @CreatedBy from an X-User header through AuditorAware, a Clock-backed DateTimeProvider, the bulk and native updates that bypass auditing, and Hibernate @CreationTimestamp and @UpdateTimestamp compared.

[Spring Boot Basics] @Transactional in Spring Boot: What a Transaction Is, Where to Put It and When It Rolls Back

@Transactional in Spring Boot 4.1.1 with PostgreSQL: the partial write an order leaves without a transaction, the JpaTransactionManager DEBUG log for begin, commit and rollback, the CGLIB proxy behind the injected bean, service method vs repository and class vs method level, rollback rules for unchecked and checked exceptions, rollbackFor, noRollbackFor and caught exceptions, what readOnly does to dirty checking and to PostgreSQL writes, self-invocation, private and protected methods, UnexpectedRollbackException from the rollback-only trap, jakarta.transaction.Transactional and TransactionTemplate.

[Spring Boot Basics] Productivity Tools in Spring Boot: DevTools, Lombok and Actuator Basics

Spring Boot DevTools, Lombok and Actuator on Spring Boot 4.1.1: why developmentOnly keeps DevTools out of bootJar, the base and restart classloaders with a measured 0.185 s restart against a 1.488 s cold start, triggering restarts with ./gradlew -t classes, why a Gradle resource build restarts the app anyway, the property defaults DevTools applies and LiveReload deprecated in 4.1.0; what Lombok generates according to javap, @Value and @Builder against Java records with Jackson 3 and @Jacksonized, the @Data entity traps (StackOverflowError, a HashSet that loses an entity, LazyInitializationException, @Builder without a no-args constructor) and the safe subset; Actuator /actuator, /actuator/health with show-details and a 503 DOWN, exposure of /actuator/info with build, git, java and os info, why include=* is dangerous, and securing Actuator next to a securityMatcher("/api/**") chain.

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1: SLF4J as the facade and Logback as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.