Appearance
JUnit 5 Assertions Deep Dive and Exception Handling
The Quality Inspector Analogy
Imagine a production line assembling printed circuit boards. At the end of the line sits a quality inspector with a checklist. For each board that passes by, the inspector checks: Does the voltage reading match the expected value? Are all components in the correct position? Does the board reject a signal that is out of range? Only when every item on the checklist is satisfied does the inspector stamp the board as approved. If even one check fails, the entire board is rejected and the specific check that failed is noted.
JUnit 5 assertions are that quality inspector. After you have arranged your test data and invoked the method under test, assertions are how you declare your expectations. If any assertion fails, the test fails immediately — and JUnit tells you exactly which assertion failed and what the discrepancy was.
This lecture covers every major assertion in JUnit 5's Assertions class, including proper use of custom objects, exception handling patterns, timeout verification, and grouped assertions.
The Assert Section in the AAA Pattern
Every test method follows the Arrange, Act, Assert (AAA) pattern. Assertions live in the final step:
java
@Test
void multiply_shouldReturnProduct() {
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.multiply(5, 6);
// Assert
assertEquals(30, result); // <-- assertion
}All assertion methods live in org.junit.jupiter.api.Assertions. Import them statically:
java
import static org.junit.jupiter.api.Assertions.*;assertEquals and assertNotEquals
assertEquals
Checks that two values are equal. For primitives, it uses ==. For objects, it uses .equals().
java
@Test
void assertEquals_primitiveExample() {
Calculator calc = new Calculator();
int result = calc.multiply(5, 6);
assertEquals(30, result); // primitive: uses ==
}
@Test
void assertEquals_stringExample() {
// String overrides .equals() — compares characters, not reference
String expected = "hello";
String actual = new String("hello");
assertEquals(expected, actual); // passes: String.equals() compares content
}The Custom Object Pitfall
This is one of the most common mistakes developers make. If your class does not override equals(), then assertEquals falls back to Object.equals(), which compares object references, not field values:
java
// NO equals() override
public class MyService {
private final String name;
public MyService(String name) {
this.name = name;
}
}
@Test
void assertEquals_withoutOverridingEquals_FAILS() {
MyService obj1 = new MyService("XYZ");
MyService obj2 = new MyService("XYZ"); // same name, different object
// FAILS: Object.equals() compares references; obj1 and obj2 are distinct objects
assertEquals(obj1, obj2);
}Fix: override equals() (and hashCode()) in your custom class:
java
public class MyService {
private final String name;
public MyService(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof MyService)) return false;
MyService other = (MyService) o;
return Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
@Test
void assertEquals_withEqualsOverridden_PASSES() {
MyService obj1 = new MyService("XYZ");
MyService obj2 = new MyService("XYZ");
assertEquals(obj1, obj2); // passes: uses overridden equals()
}assertNotEquals
Passes when the two values are not equal:
java
@Test
void assertNotEquals_example() {
Calculator calc = new Calculator();
int result = calc.multiply(5, 6); // 30
assertNotEquals(11, result); // passes: 30 != 11
}assertArrayEquals
Verifies that two arrays contain the same elements in the same order. Element comparison uses == for primitives and .equals() for objects.
java
@Test
void assertArrayEquals_intArray() {
int[] sorted = sortAscending(new int[]{3, 2, 1});
assertArrayEquals(new int[]{1, 2, 3}, sorted);
}
@Test
void assertArrayEquals_stringArray() {
// String elements use String.equals() — content comparison
String[] expected = {"hello", "world"};
String[] actual = {new String("hello"), new String("world")};
assertArrayEquals(expected, actual); // passes: String.equals() compares content
}assertIterableEquals
Compares two Iterable collections element by element using .equals(). Collections can only hold objects (not primitives), so .equals() is always used.
Important caveat about Set: Sets do not guarantee iteration order. assertIterableEquals depends on iteration sequence matching, so it is not recommended for Set comparisons.
java
@Test
void assertIterableEquals_orderedList() {
List<String> expected = List.of("A", "B", "C");
List<String> actual = new ArrayList<>(List.of("A", "B", "C"));
assertIterableEquals(expected, actual); // passes
}
@Test
void assertIterableEquals_priorityQueue() {
// PriorityQueue iterates in heap order, not insertion order
// Insertion: 3, 1, 2 → iteration: 1, 2, 3 (natural ordering)
PriorityQueue<Integer> queue = new PriorityQueue<>(List.of(3, 1, 2));
List<Integer> expectedByIterationOrder = List.of(1, 2, 3);
// assertIterableEquals uses iteration order, so this passes:
assertIterableEquals(expectedByIterationOrder, queue);
}assertLinesMatch
Both expected and actual must be List<String>. Each element is treated as a "line." The key differentiator: you may use regular expressions in the expected list.
java
@Test
void assertLinesMatch_plainStrings() {
List<String> expected = List.of("hello", "world");
List<String> actual = List.of("hello", "world");
assertLinesMatch(expected, actual); // passes
}
@Test
void assertLinesMatch_withRegex() {
// Expected line uses a regex pattern
List<String> expected = List.of("hello", "world from [a-zA-Z]+");
List<String> actual = List.of("hello", "world from Amsterdam");
assertLinesMatch(expected, actual); // passes: "world from Amsterdam" matches the regex
}
@Test
void assertLinesMatch_regexMismatch() {
List<String> expected = List.of("hello", "[a-zA-Z]+"); // only letters allowed
List<String> actual = List.of("hello", "world123"); // has digits
// Fails: "world123" does not match "[a-zA-Z]+"
// Failure message: "line 2 does not match"
assertLinesMatch(expected, actual);
}assertSame and assertNotSame
These check reference equality — whether two variables point to the exact same object in memory. They use ==, not .equals().
java
@Test
void assertSame_stringLiterals() {
// String literals are interned — both point to the same pool object
String obj1 = "JUnit";
String obj2 = "JUnit";
assertSame(obj1, obj2); // passes: same reference in string pool
}
@Test
void assertSame_newObjects_FAILS() {
String obj1 = "JUnit";
String obj2 = new String("JUnit"); // explicitly new object; not from the pool
// Fails: obj1 and obj2 point to different objects
assertSame(obj1, obj2);
}
@Test
void assertNotSame_newObjects() {
String obj1 = "JUnit";
String obj2 = new String("JUnit");
assertNotSame(obj1, obj2); // passes: they are different references
}assertNull and assertNotNull
java
@Test
void assertNull_example() {
String value = null;
assertNull(value); // passes
}
@Test
void assertNotNull_example() {
String value = "JUnit 5";
assertNotNull(value); // passes
}assertTrue and assertFalse
Use these for boolean conditions or predicate results:
java
@Test
void assertTrue_example() {
int age = 25;
boolean isAdult = age >= 18;
assertTrue(isAdult, "Expected user to be an adult"); // passes
}
@Test
void assertFalse_example() {
List<String> list = new ArrayList<>();
assertFalse(list.isEmpty() == false); // passes: isEmpty() is true, so !isEmpty() is false
}fail — Deliberately Failing a Test
Use fail() when you want to explicitly fail the test from inside control flow — most commonly inside a try block where you expect an exception to be thrown:
java
@Test
void fail_exampleUsage() {
Calculator calc = new Calculator();
try {
calc.divideByZero(); // this SHOULD throw ArithmeticException
fail("Expected ArithmeticException was not thrown"); // if we reach here, test must fail
} catch (ArithmeticException e) {
// Expected — test passes because the exception was thrown
assertEquals("/ by zero", e.getMessage());
}
}Note: assertThrows (covered below) is the more idiomatic modern approach. fail() is useful in older patterns or when you need fine grained control over the catch block.
assertInstanceOf
Checks that an object is an instance of a given class or any of its subclasses:
java
@Test
void assertInstanceOf_withSubclass() {
Number n = Integer.valueOf(10); // Integer is a subclass of Number
assertInstanceOf(Number.class, n); // passes: Integer is-a Number
assertInstanceOf(Integer.class, n); // passes: exact match
}
@Test
void assertInstanceOf_negativeCase_FAILS() {
Number n = Integer.valueOf(10);
// Fails: Integer is not a Double, nor a subclass of Double
assertInstanceOf(Double.class, n);
}assertThrows and assertThrowsExactly
assertThrows
Passes if the executable throws the specified exception type or any of its subclasses. The second parameter is an Executable — a functional interface — because JUnit needs to invoke the code itself inside a try/catch:
java
@Test
void assertThrows_withParentClass() {
// Integer.parseInt("abc") throws NumberFormatException
// NumberFormatException extends IllegalArgumentException extends Exception
assertThrows(Exception.class, () -> Integer.parseInt("abc")); // passes
// Also passes with the exact type:
assertThrows(NumberFormatException.class, () -> Integer.parseInt("abc")); // passes
}
@Test
void assertThrows_captureExceptionForFurtherAssertions() {
NumberFormatException thrown = assertThrows(
NumberFormatException.class,
() -> Integer.parseInt("not-a-number")
);
// Now assert details on the exception itself
assertTrue(thrown.getMessage().contains("not-a-number"));
}Why Use a Lambda (Executable)?
JUnit must control the execution of the code to intercept the exception:
java
// WRONG — JUnit cannot catch this; the test itself will fail with the exception:
// Integer.parseInt("abc"); // throws here — never reaches assertThrows
// assertThrows(NumberFormatException.class, ???);
// CORRECT — JUnit invokes the lambda, wraps it in try/catch, then compares:
assertThrows(NumberFormatException.class, () -> Integer.parseInt("abc"));assertThrowsExactly
Passes only if the exception type is an exact match — no subclasses:
java
@Test
void assertThrowsExactly_FAILS_withParentClass() {
// NumberFormatException is thrown, but we are checking for Exception (parent)
// assertThrowsExactly requires the exact type → FAILS
assertThrowsExactly(Exception.class, () -> Integer.parseInt("abc"));
}
@Test
void assertThrowsExactly_PASSES_withExactType() {
assertThrowsExactly(NumberFormatException.class, () -> Integer.parseInt("abc")); // passes
}assertDoesNotThrow
Passes when the executable completes without throwing any exception. Fails if any exception is thrown:
java
@Test
void assertDoesNotThrow_validParse() {
// "123" is a valid integer — no exception expected
assertDoesNotThrow(() -> Integer.parseInt("123")); // passes
}
@Test
void assertDoesNotThrow_FAILS_whenExceptionThrown() {
// "abc" is not a valid integer — NumberFormatException will be thrown → test FAILS
assertDoesNotThrow(() -> Integer.parseInt("abc"));
}assertTimeout and assertTimeoutPreemptively
assertTimeout
Fails if the executable does not complete within the given duration. However, the method runs to completion even if the timeout is exceeded. The failure is reported only after execution ends:
java
@Test
void assertTimeout_passesWhenCodeFinishesInTime() {
// Code takes 50ms, timeout is 100ms → passes
assertTimeout(Duration.ofMillis(100), () -> {
Thread.sleep(50);
doWork();
});
}
@Test
void assertTimeout_failsButWaitsForCompletion() {
// Code takes 150ms, timeout is 100ms → test fails, but only after 150ms
assertTimeout(Duration.ofMillis(100), () -> {
Thread.sleep(150); // code runs to completion regardless
});
}assertTimeoutPreemptively
Runs the executable in a separate thread. If the timeout is exceeded, the thread is interrupted and the test fails immediately — without waiting for the code to finish:
java
@Test
void assertTimeoutPreemptively_abortsImmediately() {
// Code runs in a separate thread; if it takes longer than 100ms,
// the test fails immediately (does NOT wait 150ms)
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
Thread.sleep(150); // interrupted at 100ms; test fails at that point
});
}| Aspect | assertTimeout | assertTimeoutPreemptively |
|---|---|---|
| Thread | Same thread as test | Separate thread |
| On timeout | Waits for completion, then fails | Interrupts immediately and fails |
ThreadLocal access | Safe | Not safe (different thread) |
assertAll — Grouped Assertions
By default, the first failing assertion stops the test. assertAll runs all assertions and reports all failures together, giving you the complete picture of what went wrong:
java
@Test
void assertAll_reportsAllFailures() {
String value = ""; // empty string
// All three assertions run regardless of individual failures:
assertAll("string validations",
() -> assertEquals(6, value.length(), "Expected length 6"),
() -> assertTrue(value.startsWith("J"), "Expected to start with J"),
() -> assertFalse(value.isEmpty(), "Expected non-empty string")
);
// Output when value is "":
// Expected length 6, actual was 0
// Expected to start with J, but was false
// Expected non-empty string, but was true
// (All three failures reported at once)
}
@Test
void assertAll_passesWhenAllSucceed() {
String value = "JUnit5";
assertAll("string validations",
() -> assertEquals(6, value.length()),
() -> assertTrue(value.startsWith("J")),
() -> assertFalse(value.isEmpty())
);
// All pass — test succeeds
}Adding Custom Failure Messages
Every assertion method accepts an optional String message or Supplier<String> as its last argument. Use Supplier<String> when building the message is expensive (it is only evaluated on failure):
java
@Test
void assertions_withCustomMessages() {
int result = calculator.multiply(4, 2);
// Static message — always constructed
assertEquals(8, result, "multiply(4,2) should return 8");
// Lazy message — only constructed if the assertion fails (preferred for complex messages)
assertEquals(8, result, () -> "Expected 8 but got " + result + " for inputs 4 and 2");
}Complete Example: Testing a Real Service
java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.List;
class UserServiceTest {
private UserService userService;
@BeforeEach
void setUp() {
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice", 30)));
when(mockRepo.findById(99L)).thenReturn(Optional.empty());
userService = new UserService(mockRepo);
}
@Test
void findById_shouldReturnCorrectUser() {
User user = userService.findById(1L).orElseThrow();
assertAll("user fields",
() -> assertEquals(1L, user.getId()),
() -> assertEquals("Alice", user.getName()),
() -> assertEquals(30, user.getAge()),
() -> assertNotNull(user.getName()),
() -> assertTrue(user.getAge() >= 18)
);
}
@Test
void findById_shouldThrowWhenNotFound() {
NoSuchElementException ex = assertThrows(
NoSuchElementException.class,
() -> userService.findById(99L).orElseThrow()
);
assertEquals("No value present", ex.getMessage());
}
@Test
void findAll_shouldCompleteWithinOneSecond() {
assertTimeout(Duration.ofSeconds(1), () -> userService.findAll());
}
}Interview Questions & Pitfalls
Q1: What happens when assertEquals is used with a custom object that does not override equals()?
It falls back to Object.equals(), which compares object references. Two distinct objects with identical field values will not be equal by reference, so the assertion fails. Always override equals() (and hashCode()) in domain objects used in assertions.
Q2: What is the difference between assertThrows and assertThrowsExactly?
assertThrows passes if the thrown exception is the specified type or any subclass. assertThrowsExactly passes only if the thrown exception is the exact specified type — no subclasses. Use assertThrowsExactly when your API contract guarantees a specific exception and you want to ensure subclasses are not silently swallowed.
Q3: Why must assertThrows receive a lambda rather than a direct method call?
JUnit must be able to invoke the code inside its own try/catch block to intercept the exception and compare its type. If you call the method directly, the exception propagates out of the test method before JUnit can catch it, and the test fails with an unexpected exception rather than a clean assertion failure.
Q4: What is the difference between assertTimeout and assertTimeoutPreemptively?
Both verify that code completes within a given duration. assertTimeout runs the code on the same thread and waits for it to finish even if the timeout is exceeded, failing only after completion. assertTimeoutPreemptively runs the code on a separate thread and interrupts it the moment the timeout is exceeded, failing immediately. The trade off: assertTimeoutPreemptively cannot safely access ThreadLocal values.
Q5: When should you use assertAll instead of individual assertions?
Use assertAll when you want to verify multiple related properties of the same result in one test and see all failures at once. Individual assertions stop at the first failure, leaving the rest unexamined. assertAll runs every assertion and reports each failure, which is especially useful when debugging multi-field objects.
Q6: What is the Executable functional interface in the context of JUnit 5 assertions?
Executable is a @FunctionalInterface defined by JUnit 5 (org.junit.jupiter.api.function.Executable). It declares a single void execute() throws Throwable method. It is used by assertThrows, assertDoesNotThrow, assertTimeout, and assertAll to wrap code blocks that JUnit 5 needs to control — invoking them inside its own try/catch or timing mechanism.