Skip to main content
Java · lesson 48 of 66
Open the Java page →
Java advanced Lesson 48 of 66

Also part of: JUnit

Advanced Testing in Java

Advanced Java testing by running it: custom JUnit 5 extensions, nested and dynamic tests, Mockito spies and strict stubs, H2 and Testcontainers.

By SkillByExample · Updated

This lesson goes past the basics in Unit Testing with JUnit 5 and Mockito: writing your own JUnit extensions, structuring tests with @Nested and @TestFactory, the less common Mockito tools, and integration tests against H2 and a real PostgreSQL started by Testcontainers.

Examples are compiled with javac and run with the JUnit console launcher, with the jars in lib/ (JUnit console standalone 1.11.4, Mockito 5.14.2 with its byte-buddy and objenesis dependencies, H2 2.2.224, and Testcontainers 1.21.4 with the PostgreSQL driver). As before, sed cuts off the launcher’s timing line and counts table, and each class lives in its own file in one folder. The equivalent Maven dependencies:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.21.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.21.4</version>
    <scope>test</scope>
</dependency>

A custom extension

import java.time.*;
import org.junit.jupiter.api.extension.*;

public class FixedClockExtension implements ParameterResolver, BeforeEachCallback, TestWatcher {
    public void beforeEach(ExtensionContext ctx) {
        System.out.println("before " + ctx.getDisplayName());
    }
    public boolean supportsParameter(ParameterContext p, ExtensionContext e) {
        return p.getParameter().getType() == Clock.class;
    }
    public Object resolveParameter(ParameterContext p, ExtensionContext e) {
        return Clock.fixed(Instant.parse("2026-01-15T09:00:00Z"), ZoneOffset.UTC);
    }
    public void testSuccessful(ExtensionContext ctx) {
        System.out.println("passed " + ctx.getDisplayName());
    }
    public void testFailed(ExtensionContext ctx, Throwable cause) {
        System.out.println("FAILED " + ctx.getDisplayName() + ": " + cause.getMessage());
    }
}
import java.time.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(FixedClockExtension.class)
@TestMethodOrder(MethodOrderer.MethodName.class)
class InvoiceDueDateTest {
    static LocalDate dueDate(Clock clock) { return LocalDate.now(clock).plusDays(30); }

    @Test void dueIn30Days(Clock clock) {
        assertEquals(LocalDate.of(2026, 2, 14), dueDate(clock));
    }

    @Test void wrongYear(Clock clock) {
        assertEquals(2027, dueDate(clock).getYear(), "year");
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c InvoiceDueDateTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d' | sed '/^Failures/,$d'
before dueIn30Days(Clock)
passed dueIn30Days(Clock)
before wrongYear(Clock)
FAILED wrongYear(Clock): year ==> expected: <2027> but was: <2026>
╷
└─ JUnit Jupiter ✔
   └─ InvoiceDueDateTest ✔
      ├─ dueIn30Days(Clock) ✔
      └─ wrongYear(Clock) ✘ year ==> expected: <2027> but was: <2026>

One extension class implements three hooks: BeforeEachCallback runs before every test, ParameterResolver lets test methods ask for a Clock parameter, and TestWatcher hears each outcome. Injecting a fixed clock makes date logic deterministic — the test asserts an exact date instead of whatever LocalDate.now() returns today.

Nested tests and temporary directories

import java.nio.file.*;
import java.util.ArrayDeque;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("A stack")
class StackTest {
    ArrayDeque<String> stack = new ArrayDeque<>();

    @Test @DisplayName("is empty when new") void isEmpty() { assertTrue(stack.isEmpty()); }

    @Nested @DisplayName("after pushing an element")
    class AfterPush {
        @BeforeEach void push() { stack.push("a"); }

        @Test @DisplayName("is no longer empty") void notEmpty() { assertFalse(stack.isEmpty()); }
        @Test @DisplayName("returns it on pop") void pop() { assertEquals("a", stack.pop()); }
    }

    @Test @DisplayName("can be saved to a file")
    void save(@TempDir Path dir) throws Exception {
        stack.push("x");
        Path file = Files.writeString(dir.resolve("stack.txt"), String.join(",", stack));
        assertEquals("x", Files.readString(file));
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c StackTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
   └─ A stack ✔
      ├─ can be saved to a file ✔
      ├─ is empty when new ✔
      └─ after pushing an element ✔
         ├─ returns it on pop ✔
         └─ is no longer empty ✔

A @Nested inner class shares the outer fields and runs the outer @BeforeEach methods first, then its own, so each group of tests describes one state. @DisplayName turns the report into readable sentences. @TempDir injects a fresh directory that JUnit deletes after the test.

Dynamic and repeated tests

import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class GeneratedTest {
    static boolean isPalindrome(String s) {
        String c = s.toLowerCase().replaceAll("[^a-z]", "");
        return new StringBuilder(c).reverse().toString().equals(c);
    }

    @TestFactory
    Stream<DynamicTest> palindromes() {
        return Stream.of("racecar", "Never odd or even", "hello")
            .map(word -> DynamicTest.dynamicTest("\"" + word + "\"", () -> assertTrue(isPalindrome(word))));
    }

    @RepeatedTest(value = 3, name = "attempt {currentRepetition} of {totalRepetitions}")
    void repeated(RepetitionInfo info) {
        assertTrue(info.getCurrentRepetition() <= 3);
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c GeneratedTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d' | sed '/^Failures/,$d'
╷
└─ JUnit Jupiter ✔
   └─ GeneratedTest ✔
      ├─ repeated(RepetitionInfo) ✔
      │  ├─ attempt 1 of 3 ✔
      │  ├─ attempt 2 of 3 ✔
      │  └─ attempt 3 of 3 ✔
      └─ palindromes() ✔
         ├─ "racecar" ✔
         ├─ "Never odd or even" ✔
         └─ "hello" ✘ expected: <true> but was: <false>

A @TestFactory builds tests at runtime from any data — here a stream of strings — and each DynamicTest is reported and can fail on its own, which "hello" does. @RepeatedTest runs one method several times with a RepetitionInfo parameter; it is useful for shaking out flaky code, not for retrying flaky tests until they pass.

Mockito beyond when/verify

import java.util.*;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class MockitoDeepTest {
    interface Gateway { String charge(String card, int cents); void audit(String event); }

    @Test void spyRunsRealMethodsUnlessStubbed() {
        List<String> spy = spy(new ArrayList<String>());
        spy.add("one");
        doReturn(100).when(spy).size();
        assertEquals(100, spy.size());
        assertEquals("one", spy.get(0));
        verify(spy).add("one");
    }

    @Test void consecutiveAndComputedAnswers() {
        Gateway g = mock(Gateway.class);
        when(g.charge(eq("bad"), anyInt())).thenThrow(new IllegalStateException("declined"));
        when(g.charge(eq("ok"), anyInt())).thenReturn("PAY-1", "PAY-2")
                                          .thenAnswer(inv -> "PAY-" + inv.getArgument(1));
        assertEquals(List.of("PAY-1", "PAY-2", "PAY-500"),
            List.of(g.charge("ok", 100), g.charge("ok", 200), g.charge("ok", 500)));
        assertThrows(IllegalStateException.class, () -> g.charge("bad", 1));
    }

    @Test void callsHappenInOrder() {
        Gateway g = mock(Gateway.class);
        g.audit("start"); g.charge("ok", 1); g.audit("end");
        InOrder order = inOrder(g);
        order.verify(g).audit("start");
        order.verify(g).charge("ok", 1);
        order.verify(g).audit("end");
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c MockitoDeepTest -e junit-jupiter --disable-banner --disable-ansi-colors 2>/dev/null | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
   └─ MockitoDeepTest ✔
      ├─ spyRunsRealMethodsUnlessStubbed() ✔
      ├─ callsHappenInOrder() ✔
      └─ consecutiveAndComputedAnswers() ✔

A spy wraps a real ArrayList: add and get run for real, only size is stubbed — note doReturn(...).when(spy), because when(spy.size()) would call the real method first. thenReturn("PAY-1", "PAY-2") answers successive calls in turn and the last answer repeats; thenAnswer computes a reply from the arguments. InOrder checks sequence, not just occurrence. (Stderr is discarded to hide the JDK 21 dynamic-agent warning Mockito triggers.)

Strict stubs catch stale tests

import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class StrictStubsTest {
    interface Prices { Optional<Integer> find(String sku); }
    @Mock Prices prices;

    @Test void staleStub() {
        when(prices.find("SKU-1")).thenReturn(Optional.of(500));
        when(prices.find("SKU-2")).thenReturn(Optional.of(900));   // never used
        assertEquals(500, prices.find("SKU-1").orElseThrow());
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c StrictStubsTest -e junit-jupiter --disable-banner --disable-ansi-colors 2>/dev/null | sed -n '/^╷/,/^$/p'
╷
└─ JUnit Jupiter ✔
   └─ StrictStubsTest ✔
      └─ staleStub() ✘ 
               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 StrictStubsTest.staleStub(StrictStubsTest.java:16)
               Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.

The assertion passed, yet the test failed: with MockitoExtension, Mockito runs in strict-stubs mode and rejects stubs the code never used. A leftover stub usually means the test no longer describes what the code does — or that the real call used different arguments. Delete it, or wrap a genuinely optional stub in lenient().

Integration test with H2 and rollback

import java.sql.*;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.MethodName.class)
class UserTableTest {
    Connection conn;

    @BeforeAll void createSchema() throws SQLException {          // not static: PER_CLASS
        conn = DriverManager.getConnection("jdbc:h2:mem:users");
        conn.createStatement().execute("CREATE TABLE users(email VARCHAR(50) UNIQUE)");
        conn.setAutoCommit(false);
    }
    @AfterEach void rollback() throws SQLException { conn.rollback(); }
    @AfterAll void close() throws SQLException { conn.close(); }

    int count() throws SQLException {
        ResultSet rs = conn.createStatement().executeQuery("SELECT COUNT(*) FROM users");
        rs.next();
        return rs.getInt(1);
    }

    @Test void a_insertsOne() throws SQLException {
        conn.createStatement().execute("INSERT INTO users VALUES ('ada@x.io')");
        assertEquals(1, count());
    }

    @Test void b_startsEmptyAgain() throws SQLException { assertEquals(0, count()); }

    @Test void c_rejectsDuplicates() throws SQLException {
        conn.createStatement().execute("INSERT INTO users VALUES ('ada@x.io')");
        assertThrows(SQLIntegrityConstraintViolationException.class,
            () -> conn.createStatement().execute("INSERT INTO users VALUES ('ada@x.io')"));
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c UserTableTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
   └─ UserTableTest ✔
      ├─ a_insertsOne() ✔
      ├─ b_startsEmptyAgain() ✔
      └─ c_rejectsDuplicates() ✔

PER_CLASS lifecycle keeps one test instance, so @BeforeAll can be an instance method that sets up a shared connection. Each test runs in a transaction rolled back by @AfterEach, which is why b_startsEmptyAgain sees zero rows even though a_insertsOne ran first. The method-name ordering is only there to prove that point — isolated tests must pass in any order.

Testcontainers: a real PostgreSQL

import java.sql.*;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import static org.junit.jupiter.api.Assertions.*;

@Testcontainers
class PostgresUserTest {
    @Container
    static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine");

    Connection connect() throws SQLException {
        return DriverManager.getConnection(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword());
    }

    @Test void uniqueEmailEnforcedByPostgres() throws SQLException {
        try (Connection c = connect(); Statement s = c.createStatement()) {
            s.execute("CREATE TABLE users(id SERIAL PRIMARY KEY, email TEXT UNIQUE, tags TEXT[])");
            s.execute("INSERT INTO users(email, tags) VALUES ('ada@x.io', ARRAY['admin'])");
            SQLException e = assertThrows(SQLException.class,
                () -> s.execute("INSERT INTO users(email) VALUES ('ada@x.io')"));
            System.out.println(e.getSQLState() + " " + e.getMessage().lines().findFirst().orElseThrow());

            ResultSet rs = s.executeQuery("SELECT tags[1] FROM users WHERE 'admin' = ANY(tags)");
            assertTrue(rs.next());
            assertEquals("admin", rs.getString(1));
        }
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c PostgresUserTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d'
23505 ERROR: duplicate key value violates unique constraint "users_email_key"
╷
└─ JUnit Jupiter ✔
   └─ PostgresUserTest ✔
      └─ uniqueEmailEnforcedByPostgres() ✔

The @Container field makes the extension start a throwaway postgres:16-alpine container in Docker before the class runs and stop it afterwards; the test gets its JDBC URL, user and password from the container. The test uses things H2 would not reproduce faithfully: a TEXT[] array column with ANY, and PostgreSQL’s own SQLSTATE 23505 and error text for the unique violation.

The test pyramid

LayerToolsHow manySpeed
UnitJUnit 5, Mockito, fakesmanymilliseconds
IntegrationH2, Testcontainers, @SpringBootTestsomeseconds
End-to-endPlaywright, Selenium, HTTP clientsfewslow, brittle

Push each check as low as it can go: a pricing rule belongs in a unit test, a SQL query in an integration test, and only the critical user journeys in end-to-end tests.

Practice

  1. Make the palindromes factory’s failing case pass by replacing "hello" with another palindrome, and add a @DisplayName to the factory method.
Solution
import java.util.stream.Stream;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class PalindromeFactoryTest {
    static boolean isPalindrome(String s) {
        String c = s.toLowerCase().replaceAll("[^a-z]", "");
        return new StringBuilder(c).reverse().toString().equals(c);
    }

    @TestFactory @DisplayName("palindromes")
    Stream<DynamicTest> palindromes() {
        return Stream.of("racecar", "Never odd or even", "Was it a car or a cat I saw")
            .map(w -> DynamicTest.dynamicTest(w, () -> assertTrue(isPalindrome(w))));
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c PalindromeFactoryTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
   └─ PalindromeFactoryTest ✔
      └─ palindromes ✔
         ├─ racecar ✔
         ├─ Never odd or even ✔
         └─ Was it a car or a cat I saw ✔
  1. Fix StrictStubsTest without deleting the second stub, by marking it lenient.
Solution
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class LenientStubTest {
    interface Prices { Optional<Integer> find(String sku); }
    @Mock Prices prices;

    @Test void optionalStub() {
        when(prices.find("SKU-1")).thenReturn(Optional.of(500));
        lenient().when(prices.find("SKU-2")).thenReturn(Optional.of(900));
        assertEquals(500, prices.find("SKU-1").orElseThrow());
    }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c LenientStubTest -e junit-jupiter --disable-banner --disable-ansi-colors 2>/dev/null | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
   └─ LenientStubTest ✔
      └─ optionalStub() ✔
  1. Add an AfterTestExecutionCallback to an extension that prints whether each test threw, and register it on a class with one passing and one failing test.
Solution
import org.junit.jupiter.api.extension.*;

public class OutcomeExtension implements AfterTestExecutionCallback {
    public void afterTestExecution(ExtensionContext ctx) {
        String outcome = ctx.getExecutionException().map(e -> "threw " + e.getClass().getSimpleName()).orElse("ok");
        System.out.println(ctx.getRequiredTestMethod().getName() + ": " + outcome);
    }
}
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(OutcomeExtension.class)
@TestMethodOrder(MethodOrderer.MethodName.class)
class OutcomeTest {
    @Test void a_passes() { assertEquals(4, 2 + 2); }
    @Test void b_fails() { assertEquals(5, 2 + 2); }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c OutcomeTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^╷/,$d'
a_passes: ok
b_fails: threw AssertionFailedError

getExecutionException() is filled in when the test body threw, which includes assertion failures.

Frequently Asked Questions

What is the difference between a mock, a stub and a spy?
A stub is a fake that returns canned answers. A mock is a fake whose interactions you also verify afterwards. A spy wraps a real object, running real methods except the ones you stub, and records every call. In Mockito, mock() creates a fake from scratch and spy() wraps an existing instance.
When should I use Testcontainers instead of an in-memory database like H2?
Use Testcontainers when behaviour depends on the real database engine: SQL dialect, JSON or array columns, constraint error messages, locking or extensions. H2 is faster and needs no Docker, which suits tests of plain JDBC or SQL that every engine supports. Many teams run fast unit tests with fakes and a smaller integration suite against a real container.
What is a JUnit 5 extension?
An extension is a class that hooks into the test lifecycle through interfaces such as BeforeEachCallback, TestWatcher or ParameterResolver, and is registered with @ExtendWith. MockitoExtension, SpringExtension and the Testcontainers extension are all built this way. Writing your own lets you share setup logic, inject parameters or react to test results without copying code into each test class.
What does Mockito's UnnecessaryStubbingException mean?
With MockitoExtension, Mockito runs in strict-stubs mode and fails a test that stubs a method the code under test never calls. It usually means the test is out of date or the stub uses different arguments from the real call. Remove the stub, fix the arguments, or mark a genuinely optional stub with lenient().
How do I keep database tests isolated from each other?
Give each test a clean state. Common approaches are running each test in a transaction that is rolled back afterwards, truncating tables in @BeforeEach, or starting a fresh database per test class. Never rely on test execution order, because JUnit does not promise one unless you configure it.