Java · lesson 44 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: Software Testing JUnit
Unit Testing with JUnit 5 and Mockito
Learn JUnit 5 and Mockito by running real tests: lifecycle, assertions, assertAll, parameterized tests, stubbing, verify and ArgumentCaptor.
By SkillByExample · Updated
A unit test is a small program that calls your code and checks the result, so that a change which breaks behaviour fails loudly. JUnit 5 finds and runs tests; Mockito replaces a class’s dependencies with fakes you control.
Setup
In a Maven project the two libraries go on the test class path (Surefire 3.x runs JUnit 5 tests
with mvn test):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
To keep each example self-contained, this lesson compiles the files with javac and runs them
with the JUnit console launcher, with the jars (junit-platform-console-standalone-1.11.4,
mockito-core, mockito-junit-jupiter, byte-buddy, byte-buddy-agent, objenesis) in a
lib/ folder. The launcher ends with a timing line and a 12-line counts table; the commands pipe
through sed to cut that tail off, and -e junit-jupiter hides two engines these examples don’t
use. Each class goes in its own file, named after the class, and the files accumulate in one
folder as the lesson goes on — the Mockito section reuses the User record from earlier.
A first test and the lifecycle
public class Calculator {
int add(int a, int b) { return a + b; }
int divide(int a, int b) { return a / b; }
}
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@TestMethodOrder(MethodOrderer.MethodName.class)
class CalculatorTest {
Calculator calc;
@BeforeAll static void once() { System.out.println("@BeforeAll"); }
@BeforeEach void setUp() { calc = new Calculator(); System.out.println("@BeforeEach"); }
@AfterEach void tearDown() { System.out.println("@AfterEach"); }
@Test void add_twoPositives_returnsSum() { assertEquals(7, calc.add(3, 4)); }
@Test void divide_byZero_throws() {
ArithmeticException e = assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
assertEquals("/ by zero", e.getMessage());
}
@Test @DisplayName("deliberately wrong expectation")
void add_wrongExpectation() { assertEquals(8, calc.add(3, 4), "3 + 4"); }
@Test @Disabled("not implemented yet") void subtract() {}
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c CalculatorTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d'
@BeforeAll
@BeforeEach
@AfterEach
@BeforeEach
@AfterEach
@BeforeEach
@AfterEach
╷
└─ JUnit Jupiter ✔
└─ CalculatorTest ✔
├─ add_twoPositives_returnsSum() ✔
├─ deliberately wrong expectation ✘ 3 + 4 ==> expected: <8> but was: <7>
├─ divide_byZero_throws() ✔
└─ subtract() ↷ not implemented yet
Failures (1):
JUnit Jupiter:CalculatorTest:deliberately wrong expectation
MethodSource [className = 'CalculatorTest', methodName = 'add_wrongExpectation', methodParameterTypes = '']
=> org.opentest4j.AssertionFailedError: 3 + 4 ==> expected: <8> but was: <7>
org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150)
org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563)
CalculatorTest.add_wrongExpectation(CalculatorTest.java:20)
java.base/java.lang.reflect.Method.invoke(Method.java:580)
java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
@BeforeEach and @AfterEach wrap every test, while @BeforeAll ran once (it must be static,
because JUnit creates a new CalculatorTest instance per test). assertThrows returns the
exception, so its message can be checked too. The failing test shows the "3 + 4" message, the
expected and actual values, and the exact line — and the @Disabled test is reported as skipped.
Assertions and assertAll
public record User(String name, int age, String email) {}
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AssertionsTest {
User user = new User("Alice", 31, null);
@Test void individualAsserts() {
assertEquals("Alice", user.name());
assertEquals(30, user.age()); // stops here
assertNotNull(user.email()); // never reached
}
@Test void groupedAsserts() {
assertAll("user",
() -> assertEquals("Alice", user.name()),
() -> assertEquals(30, user.age()),
() -> assertNotNull(user.email(), "email"));
}
@Test void otherAssertions() {
assertEquals(0.3, 0.1 + 0.2, 1e-9);
assertIterableEquals(List.of("a", "b"), List.of("a", "b"));
assertArrayEquals(new int[] {1, 2}, new int[] {1, 2});
assertTrue(user.age() > 18, () -> "built only on failure: " + user);
}
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c AssertionsTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d' | grep -v '^ \(org\|java\)'
╷
└─ JUnit Jupiter ✔
└─ AssertionsTest ✔
├─ groupedAsserts() ✘ user (2 failures)
│ org.opentest4j.AssertionFailedError: expected: <30> but was: <31>
│ org.opentest4j.AssertionFailedError: email ==> expected: not <null>
├─ individualAsserts() ✘ expected: <30> but was: <31>
└─ otherAssertions() ✔
Failures (2):
JUnit Jupiter:AssertionsTest:groupedAsserts()
MethodSource [className = 'AssertionsTest', methodName = 'groupedAsserts', methodParameterTypes = '']
=> org.opentest4j.MultipleFailuresError: user (2 failures)
org.opentest4j.AssertionFailedError: expected: <30> but was: <31>
org.opentest4j.AssertionFailedError: email ==> expected: not <null>
AssertionsTest.groupedAsserts(AssertionsTest.java:15)
[...]
Suppressed: org.opentest4j.AssertionFailedError: expected: <30> but was: <31>
org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150)
org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:145)
[...]
Suppressed: org.opentest4j.AssertionFailedError: email ==> expected: not <null>
org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:152)
org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
org.junit.jupiter.api.AssertNotNull.failNull(AssertNotNull.java:49)
org.junit.jupiter.api.AssertNotNull.assertNotNull(AssertNotNull.java:35)
org.junit.jupiter.api.Assertions.assertNotNull(Assertions.java:312)
[...]
JUnit Jupiter:AssertionsTest:individualAsserts()
MethodSource [className = 'AssertionsTest', methodName = 'individualAsserts', methodParameterTypes = '']
=> org.opentest4j.AssertionFailedError: expected: <30> but was: <31>
AssertionsTest.individualAsserts(AssertionsTest.java:10)
A plain sequence of asserts stops at the first failure, hiding the missing email. assertAll runs
every check and reports both problems together. Doubles are compared with a tolerance (1e-9)
because 0.1 + 0.2 is not exactly 0.3. The last command also filters out JUnit’s internal stack
frames so the failures stay readable.
Parameterized tests
public class FizzBuzz {
static String of(int n) {
if (n % 15 == 0) return "FizzBuzz";
if (n % 3 == 0) return "Fizz";
if (n % 5 == 0) return "Buzz";
return String.valueOf(n);
}
}
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FizzBuzzTest {
@ParameterizedTest
@ValueSource(ints = {3, 6, 9})
void multiplesOfThree(int n) { assertEquals("Fizz", FizzBuzz.of(n)); }
@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({"5, Buzz", "15, FizzBuzz", "7, 7"})
void table(int n, String expected) { assertEquals(expected, FizzBuzz.of(n)); }
static Stream<Arguments> bigNumbers() {
return Stream.of(Arguments.of(30, "FizzBuzz"), Arguments.of(101, "101"));
}
@ParameterizedTest
@MethodSource("bigNumbers")
void fromMethod(int n, String expected) { assertEquals(expected, FizzBuzz.of(n)); }
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c FizzBuzzTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ FizzBuzzTest ✔
├─ multiplesOfThree(int) ✔
│ ├─ [1] 3 ✔
│ ├─ [2] 6 ✔
│ └─ [3] 9 ✔
├─ table(int, String) ✔
│ ├─ 5 -> Buzz ✔
│ ├─ 15 -> FizzBuzz ✔
│ └─ 7 -> 7 ✔
└─ fromMethod(int, String) ✔
├─ [1] 30, FizzBuzz ✔
└─ [2] 101, 101 ✔
One method becomes many test cases, one per row, each reported separately so a failure names the
exact input. @ValueSource supplies single values, @CsvSource rows of arguments (converted to the
parameter types), and @MethodSource any objects a static factory returns. name = "{0} -> {1}"
controls how each case is labelled.
Mocking with Mockito: stub, then verify
import java.util.Optional;
public interface UserRepository {
Optional<User> findByEmail(String email);
User save(User user);
}
public interface EmailService { void sendWelcome(String email); }
public class UserService {
private final UserRepository repo;
private final EmailService email;
public UserService(UserRepository repo, EmailService email) { this.repo = repo; this.email = email; }
public User register(String name, String address) {
if (repo.findByEmail(address).isPresent())
throw new IllegalStateException("already registered: " + address);
User saved = repo.save(new User(name.strip(), 0, address.toLowerCase()));
email.sendWelcome(saved.email());
return saved;
}
}
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository repo;
@Mock EmailService email;
@InjectMocks UserService service;
@Captor ArgumentCaptor<User> saved;
@Test void register_newUser_savesNormalisedAndSendsEmail() {
when(repo.findByEmail("Bob@Example.com")).thenReturn(Optional.empty());
when(repo.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.register(" Bob ", "Bob@Example.com");
verify(repo).save(saved.capture());
assertEquals(new User("Bob", 0, "bob@example.com"), saved.getValue());
verify(email).sendWelcome("bob@example.com");
}
@Test void register_duplicate_throwsAndSendsNothing() {
when(repo.findByEmail("a@b.com")).thenReturn(Optional.of(new User("A", 1, "a@b.com")));
assertThrows(IllegalStateException.class, () -> service.register("A", "a@b.com"));
verify(repo, never()).save(any());
verifyNoInteractions(email);
}
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c UserServiceTest -e junit-jupiter --disable-banner --disable-ansi-colors 2>/dev/null | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ UserServiceTest ✔
├─ register_duplicate_throwsAndSendsNothing() ✔
└─ register_newUser_savesNormalisedAndSendsEmail() ✔
@Mock creates fakes and @InjectMocks passes them to UserService’s constructor. The test
stubs what the repository returns, runs the method, and then verifies the side effects:
ArgumentCaptor grabs the exact User that was saved, so the test can check the trimming and
lower-casing. never() and verifyNoInteractions prove nothing was saved or sent for a duplicate.
The Mockito runs send stderr to /dev/null: on JDK 21 Mockito attaches its bytecode agent at
runtime and prints a notice plus several WARNING: A Java agent has been loaded dynamically lines.
In a real build, silence them by adding Mockito as a -javaagent in the Surefire argLine.
What a failed verification looks like
import java.util.Optional;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class VerifyFailureTest {
@Test void welcomeEmailGoesToTheNormalisedAddress() {
UserRepository repo = mock(UserRepository.class);
EmailService email = mock(EmailService.class);
when(repo.findByEmail(anyString())).thenReturn(Optional.empty());
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
new UserService(repo, email).register("Cy", "CY@x.io");
verify(email).sendWelcome("CY@x.io");
}
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c VerifyFailureTest -e junit-jupiter --disable-banner --disable-ansi-colors 2>/dev/null | sed -n '/^Failures/,/^$/p' | head -12
Failures (1):
JUnit Jupiter:VerifyFailureTest:welcomeEmailGoesToTheNormalisedAddress()
MethodSource [className = 'VerifyFailureTest', methodName = 'welcomeEmailGoesToTheNormalisedAddress', methodParameterTypes = '']
=> Argument(s) are different! Wanted:
emailService.sendWelcome("CY@x.io");
-> at VerifyFailureTest.welcomeEmailGoesToTheNormalisedAddress(VerifyFailureTest.java:14)
Actual invocations have different arguments:
emailService.sendWelcome("cy@x.io");
-> at UserService.register(UserService.java:10)
Mockito reports what was expected and what actually happened — the call was made, but with
cy@x.io. Here the test is wrong, not the code: the service deliberately lower-cases addresses.
Reading the “Actual invocations” section is usually the fastest way to tell which one to fix.
mock(...) without the extension works too, as this test shows.
Naming and running tests
Name tests method_scenario_expectedResult — register_duplicate_throwsAndSendsNothing tells
you what broke without opening the file. In a build tool:
| Command | Runs |
|---|---|
mvn test | all tests |
mvn test -Dtest=UserServiceTest | one class |
mvn test -Dtest='UserServiceTest#register*' | matching methods |
./gradlew test --tests '*UserServiceTest' | one class with Gradle |
Practice
- Add a
@ParameterizedTestwith@CsvSourcethat checksCalculator.addfor three rows, including negatives. The solutions reuse the classes already in the folder.
Solution
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AddTableTest {
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({"1, 2, 3", "-4, 4, 0", "-2, -3, -5"})
void add(int a, int b, int expected) {
assertEquals(expected, new Calculator().add(a, b));
}
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c AddTableTest -e junit-jupiter --disable-banner --disable-ansi-colors | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ AddTableTest ✔
└─ add(int, int, int) ✔
├─ 1 + 2 = 3 ✔
├─ -4 + 4 = 0 ✔
└─ -2 + -3 = -5 ✔
- Make the
EmailServicemock throwRuntimeException("SMTP down")withdoThrow(...).when(...)and assert thatregisterpropagates it — and that the user was still saved.
Solution
import java.util.Optional;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SmtpDownTest {
@Test void register_emailFails_propagates() {
UserRepository repo = mock(UserRepository.class);
EmailService email = mock(EmailService.class);
when(repo.findByEmail(anyString())).thenReturn(Optional.empty());
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
doThrow(new RuntimeException("SMTP down")).when(email).sendWelcome(anyString());
RuntimeException e = assertThrows(RuntimeException.class, () -> new UserService(repo, email).register("D", "d@x.io"));
assertEquals("SMTP down", e.getMessage());
verify(repo).save(any());
}
}
$ javac -d out -cp 'lib/*' *.java
$ java -cp 'out:lib/*' org.junit.platform.console.ConsoleLauncher execute -c SmtpDownTest -e junit-jupiter --disable-banner --disable-ansi-colors 2>/dev/null | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ SmtpDownTest ✔
└─ register_emailFails_propagates() ✔
Void methods cannot go inside when(...), so stubbing them uses the doThrow(...).when(mock) form.
The passing test documents a real design question: the user is saved even though the email failed.
- Fix
VerifyFailureTestso it passes, by verifying the lower-cased address.
Solution
Change the last line to verify(email).sendWelcome("cy@x.io");. The service lower-cases the
address before sending, so that is the call Mockito recorded — exactly what its “Actual
invocations” section showed.