Module 26 - Unit Testing
Unit tests verify individual classes and methods in isolation, catching regressions before they reach production. This module covers JUnit 5 (Jupiter) end-to-end and Mockito for mocking collaborators.
JUnit 5 Architecture
JUnit 5 is made up of three components:
| Component | Role |
|---|---|
| JUnit Platform | Launcher foundation; integrates with Maven Surefire, Gradle, IDEs |
| JUnit Vintage | Runs JUnit 3/4 tests on the Platform for migration |
| JUnit Jupiter | The new programming and extension model (what you write) |
The junit-jupiter artifact is an aggregator that pulls in the API, engine, and parameterized-tests support.
Lifecycle Annotations
@BeforeAll // runs once before the first test; must be static (per-method lifecycle)
static void suiteSetUp() { ... }
@AfterAll // runs once after the last test; must be static
static void suiteTearDown() { ... }
@BeforeEach // runs before every test method - use to reset mutable state
void setUp() { subject = new Calculator(); }
@AfterEach // runs after every test method
void tearDown() { ... }
The default lifecycle is PER_METHOD: a new test instance is created for each test, so @BeforeEach has a clean slate to work with every time.
Core Assertions
assertEquals(5, calculator.add(2, 3));
assertNotNull(result);
assertTrue(calculator.isPrime(7));
assertFalse(calculator.isPrime(4));
// assertAll: all assertions run even if an earlier one fails
assertAll("calculator",
() -> assertEquals(5, calculator.add(2, 3), "add"),
() -> assertEquals(12, calculator.multiply(3, 4), "multiply")
);
// assertThrows: verify exception type and inspect the instance
ArithmeticException ex = assertThrows(ArithmeticException.class,
() -> calculator.divide(10, 0));
assertEquals("Division by zero", ex.getMessage());
// assertTimeout: enforce a wall-clock time budget
assertTimeout(Duration.ofSeconds(1), () -> calculator.factorial(20));
@DisplayName
@Test
@DisplayName("2 + 3 = 5")
void addition() { ... }
Appears verbatim in IDE and CI reports - use it to describe observable behaviour rather than repeating the method name.
@Nested - Logical Grouping
@Nested
@DisplayName("Prime number detection")
class PrimeTests {
@Test void two_is_prime() { assertTrue(calculator.isPrime(2)); }
@ParameterizedTest @ValueSource(ints = {2, 3, 5, 7}) void known_primes(int n) { ... }
}
Nested classes can have their own @BeforeEach/@AfterEach that stack with the outer class’s lifecycle methods.
Parameterized Tests
// @ValueSource: single argument, one primitive per line
@ParameterizedTest @ValueSource(ints = {2, 3, 5, 7}) void primes(int n) { ... }
// @CsvSource: multiple arguments per row
@ParameterizedTest
@CsvSource({"1, 1, 2", "0, 0, 0", "-1, 1, 0"})
void addition(int a, int b, int expected) { assertEquals(expected, calc.add(a, b)); }
// @MethodSource: any type, including complex objects
static Stream<Arguments> divisionCases() {
return Stream.of(Arguments.of(10.0, 2.0, 5.0), ...);
}
@ParameterizedTest @MethodSource("divisionCases")
void division(double a, double b, double expected) { ... }
// @EnumSource: test against enum constants
@ParameterizedTest
@EnumSource(value = DayOfWeek.class, names = {"MONDAY", ..., "FRIDAY"})
void weekdays(DayOfWeek day) { assertTrue(day.getValue() <= 5); }
// @NullSource / @EmptySource / @NullAndEmptySource: edge-case null/empty inputs
@ParameterizedTest @NullSource
void null_input(String s) { assertFalse(StringUtils.isPalindrome(s)); }
@RepeatedTest
@RepeatedTest(value = 3, name = "run {currentRepetition}/{totalRepetitions}")
void idempotent_operation(RepetitionInfo info) {
assertEquals(7, calculator.max(7, 3, 5));
}
Useful for operations that should produce the same result regardless of how many times they’re called (idempotency, randomness bounds, etc.).
Assumptions
// Aborts (skips) the test if the condition is false - not a failure
assumeTrue(System.getenv("CI") != null, "Only run in CI");
// Runs the assertion only if the condition holds; test always passes otherwise
assumingThat(!onWindows, () -> assertNotNull(System.getenv("HOME")));
@Tag and @Disabled
@Test @Tag("slow") // mvn test -Dgroups=slow to run only tagged tests
void expensive_test() { ... }
@Test @Disabled("WIP") // shows as skipped, not failed
void future_test() { ... }
@TestMethodOrder
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class MyTest {
@Test @Order(1) void first() { ... }
@Test @Order(2) void second() { ... }
// Unordered tests get Integer.MAX_VALUE / 2 as their implicit order
}
Mockito - Core Concepts
| Concept | Meaning |
|---|---|
| Mock | A fully controlled fake; all methods return defaults; void methods do nothing |
| Stub | A programmed return value: when(x.method()).thenReturn(y) |
| Spy | A partial mock wrapping a real object; real methods run unless individually stubbed |
| Verify | Assert that a mock method was (or was not) called |
| Captor | Intercept and inspect the actual argument passed to a mock |
@ExtendWith(MockitoExtension.class)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repository;
@Mock PaymentGateway paymentGateway;
@Mock NotificationService notifications;
@InjectMocks OrderService orderService; // mocks injected via constructor
@Captor ArgumentCaptor<Order> orderCaptor;
@Spy BankAccount account = new BankAccount("acc", 100.0);
}
MockitoExtension uses STRICT_STUBS by default:
- Unused stubbings fail the test (tells you what to remove)
- Argument mismatches in stubbings are reported
Stubbing
// Return value
when(gateway.charge("cust-1", 100.0)).thenReturn(PaymentResult.success("txn-1"));
// Argument matchers
when(gateway.charge(any(), anyDouble())).thenReturn(PaymentResult.failure("Declined"));
// Chain: different value on consecutive calls
when(gateway.charge(any(), anyDouble()))
.thenReturn(PaymentResult.failure("First fail"))
.thenReturn(PaymentResult.success("txn-retry"));
// Throw exception
when(gateway.charge(any(), anyDouble())).thenThrow(new RuntimeException("network error"));
// Dynamic answer via lambda
when(repo.findById(anyString()))
.thenAnswer(inv -> inv.getArgument(0, String.class).startsWith("valid-")
? Optional.of(new Order(...))
: Optional.empty());
// Void method on spy (doReturn / doThrow)
doReturn(9999.0).when(spiedAccount).balance();
doThrow(new RuntimeException()).when(notifications).sendOrderConfirmation(any(), any());
Verification
// Called exactly once (default)
verify(repository).save(any(Order.class));
// Times variants
verify(notifications, times(1)).sendOrderConfirmation(any(), any());
verify(repository, never()).save(any());
verify(gateway, atLeastOnce()).charge(any(), anyDouble());
// Nothing touched at all
verifyNoInteractions(paymentGateway, notifications);
// No further interactions beyond what was already verified
verifyNoMoreInteractions(repository);
// Ordering across mocks
InOrder inOrder = inOrder(paymentGateway, repository, notifications);
inOrder.verify(paymentGateway).charge("cust-1", 30.0);
inOrder.verify(repository).save(any());
inOrder.verify(notifications).sendOrderConfirmation(eq("cust-1"), any());
ArgumentCaptor
@Captor ArgumentCaptor<Order> orderCaptor;
orderService.placeOrder("cust-1", List.of("book"), 75.0);
verify(repository).save(orderCaptor.capture());
Order saved = orderCaptor.getValue();
assertEquals(OrderStatus.CONFIRMED, saved.status());
Use ArgumentCaptor when you need to assert on complex object state that was passed to a collaborator - not just that the method was called.
Argument Matchers
any() // any non-null object
any(Order.class) // any non-null Order
anyString() // any non-null String
anyDouble() // any double primitive
eq("exact") // exact equality (needed when mixing with any())
argThat(o -> o.total() > 0) // custom predicate
Rule: when using any() matchers in a stubbing or verify, all arguments must use matchers - you cannot mix literals and matchers.
@Spy - Partial Mocking
@Spy BankAccount account = new BankAccount("acc", 200.0);
// Real methods run
account.deposit(50.0);
verify(account).deposit(50.0);
assertEquals(250.0, account.balance()); // real state
// Override a specific method
doReturn(9999.0).when(account).balance();
assertEquals(9999.0, account.balance()); // stubbed
Use spy for legacy code or when testing that one real method delegates correctly to another without stubbing the entire class.
Test Design Principles
AAA - Arrange / Act / Assert
@Test
void deposit_increases_balance_by_exact_amount() {
// Arrange
double depositAmount = 200.0;
// Act
account.deposit(depositAmount);
// Assert
assertEquals(INITIAL_BALANCE + depositAmount, account.balance());
}
F.I.R.S.T.
| Letter | Meaning |
|---|---|
| Fast | Tests run in milliseconds; slow tests are skipped |
| Isolated | Each test sets up its own state; no shared mutable fields |
| Repeatable | Same result every run, on every machine |
| Self-validating | Pass/fail - no human inspection needed |
| Timely | Written alongside (or before) the code |
Name tests as sentences
void withdraw_entire_balance_results_in_zero()
void failed_withdrawal_leaves_state_unchanged()
void transaction_list_is_unmodifiable()
Test one concept per test
Small, focused tests are easier to diagnose. If a test fails, you know exactly which behaviour broke.
State integrity after failure
If an operation throws, the object’s state must not change:
assertThrows(InsufficientFundsException.class, () -> account.withdraw(overdraft));
assertEquals(INITIAL_BALANCE, account.balance(), "balance unchanged");
assertTrue(account.transactions().isEmpty(), "no partial transaction");
Named constants instead of magic numbers
static final double INITIAL_BALANCE = 500.0;
assertEquals(INITIAL_BALANCE + depositAmount, account.balance());
// vs: assertEquals(700.0, account.balance()) - cryptic and brittle