Java · lesson 48 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also 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
| Layer | Tools | How many | Speed |
|---|---|---|---|
| Unit | JUnit 5, Mockito, fakes | many | milliseconds |
| Integration | H2, Testcontainers, @SpringBootTest | some | seconds |
| End-to-end | Playwright, Selenium, HTTP clients | few | slow, 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
- Make the
palindromesfactory’s failing case pass by replacing"hello"with another palindrome, and add a@DisplayNameto 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 ✔
- Fix
StrictStubsTestwithout 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() ✔
- Add an
AfterTestExecutionCallbackto 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.