Java

Chapter 22: Automated Testing – Unit Testing with JUnit 5 and Mockito

By Ali Naqi | November 01, 2026

Chapter 22: Automated Testing – Unit Testing with JUnit 5 and Mockito

🧪 Chapter 22: Automated Testing – Unit Testing with JUnit 5 and Mockito

1. The Test Pyramid: Strategy and Scope

Before writing assertions, understand the operational layers of automated testing:

           / \

          / \

         / E2E \ <-- End-to-End: Slowest, brittle, expensive, full system

        /───────\

       / Integr- \ <-- Integration: Medium speed, tests DB, HTTP, wire boundaries

      / ation \

     /─────────────\

    / Unit \ <-- Unit Tests: Sub-millisecond, isolated, pure in-memory logic

   /─────────────────\


 * Unit Tests: Validate a single "unit" of code (typically a class or method) in total isolation from external infrastructure. If a test touches a live database, file system, or network socket, it is not a unit test.

 * Integration Tests: Verify that distinct modules work together correctly (e.g., repository queries against a Testcontainers database).

 * End-to-End (E2E) Tests: Validate the entire application journey from user input to backend persistence.

Unit tests form the foundation: they must execute in milliseconds, run deterministically, and never fail because a network cable was unplugged.

2. JUnit 5 Architecture: Vintage, Platform, and Jupiter

JUnit 5 represents a complete redesign over legacy JUnit 4. It decomposes into three distinct sub-projects:

 * JUnit Platform: The foundational runtime engine that discovers and launches tests on the JVM. Build tools like Maven and Gradle, as well as IDEs like IntelliJ and Eclipse, communicate directly with the Platform.

 * JUnit Jupiter: The modern programming model, annotation set, and extension engine for writing tests in Java.

 * JUnit Vintage: An execution engine backward-compatible with JUnit 3 and JUnit 4 suites.

3. Core Lifecycle Annotations

Every test method in JUnit Jupiter must be marked with @Test. Jupiter also provides explicit lifecycle hooks:

import org.junit.jupiter.api.*;


@DisplayName("User Account Lifecycle Test Suite")

class AccountLifecycleTest {


    @BeforeAll

    static void initGlobalFixtures() {

        // Runs ONCE before any test in this class executes. Must be static!

        System.out.println("Global setup: Allocating shared immutable resources");

    }


    @BeforeEach

    void setupIsolatedState() {

        // Runs BEFORE EACH individual @Test method. Resets mutable fixtures.

        System.out.println("Per-test setup: Instantiating fresh target object");

    }


    @Test

    @DisplayName("Verify deposit increases account balance correctly")

    void testDepositIncreasesBalance() {

        System.out.println("Executing: testDepositIncreasesBalance");

    }


    @Test

    @Disabled("Skipped until ticket JIRA-4091 is resolved")

    void testPendingFeature() {

        // Ignored by the test runner

    }


    @AfterEach

    void tearDownIsolatedState() {

        // Runs AFTER EACH @Test method completes.

        System.out.println("Per-test cleanup");

    }


    @AfterAll

    static void cleanupGlobalFixtures() {

        // Runs ONCE after all tests complete.

        System.out.println("Global cleanup");

    }

}


> Visibility Rule: In JUnit 5, test classes and test methods do not need to be declared public. Package-private visibility (omitting public) is standard convention.

> 

4. Assertions and Exception Verification

The org.junit.jupiter.api.Assertions class provides static methods to validate test expectations.

A. Core Assertions

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

import org.junit.jupiter.api.Test;


class AssertionBasicsTest {


    @Test

    void validateStandardAssertions() {

        String result = "order-ref-9812";

        

        assertEquals("order-ref-9812", result, "Expected explicit reference ID match");

        assertNotEquals("order-ref-0000", result);

        assertTrue(result.startsWith("order-"));

        assertFalse(result.isEmpty());

        assertNotNull(result);

    }

}


B. Grouped Assertions with assertAll()

Standard assertions fail fast: if assertion #1 fails, execution stops, leaving assertions #2 and #3 unexecuted.

assertAll() executes every assertion in the group and aggregates all failures into a single composite report:

record UserProfile(String username, String email, int loyaltyTier) {}


@Test

void validateUserProfileStructure() {

    UserProfile user = new UserProfile("alex_dev", "alex@corp.internal", 3);


    // Grouped assertion evaluates all child lambdas, even if one fails

    assertAll("User profile field validations",

        () -> assertEquals("alex_dev", user.username()),

        () -> assertTrue(user.email().endsWith("@corp.internal"), "Email domain invalid"),

        () -> assertTrue(user.loyaltyTier() >= 1 && user.loyaltyTier() <= 5, "Tier out of bounds")

    );

}


C. Testing Exceptions with assertThrows()

Verifying that your system rejects bad inputs is just as important as testing the happy path:

class BankAccount {

    private double balance = 100.0;


    public void withdraw(double amount) {

        if (amount > balance) {

            throw new IllegalArgumentException("Withdrawal exceeds balance: " + amount);

        }

        balance -= amount;

    }

}


@Test

void withdrawShouldThrowExceptionWhenInsufficientFunds() {

    BankAccount account = new BankAccount();


    // Verifies the method throws the expected exception type

    IllegalArgumentException exception = assertThrows(

        IllegalArgumentException.class,

        () -> account.withdraw(250.0),

        "Expected withdraw() to throw IllegalArgumentException, but it did not"

    );


    // Assert details on the captured exception instance

    assertTrue(exception.getMessage().contains("exceeds balance: 250.0"));

}


5. Parameterized Tests: Data-Driven Validation

Writing separate test methods to check multiple inputs violates DRY. JUnit 5 provides Parameterized Tests via @ParameterizedTest, allowing a single test method to run repeatedly against an arbitrary stream of input arguments.

(Requires the junit-jupiter-params dependency).

import org.junit.jupiter.params.ParameterizedTest;

import org.junit.jupiter.params.provider.ValueSource;

import org.junit.jupiter.params.provider.CsvSource;

import org.junit.jupiter.params.provider.MethodSource;

import java.util.stream.Stream;

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


class ValidationParameterizedTest {


    // 1. Simple values via ValueSource

    @ParameterizedTest(name = "Testing palindromes on: \"{0}\"")

    @ValueSource(strings = {"radar", "level", "kayak", "rotor"})

    void shouldRecognizePalindromes(String candidate) {

        String reversed = new StringBuilder(candidate).reverse().toString();

        assertEquals(candidate, reversed);

    }


    // 2. Multi-column inputs via CsvSource

    @ParameterizedTest(name = "Input {0} + {1} should equal {2}")

    @CsvSource({

        "1, 2, 3",

        "10, 15, 25",

        "-5, 5, 0",

        "100, 250, 350"

    })

    void testAdditionCalculations(int a, int b, int expectedSum) {

        assertEquals(expectedSum, a + b);

    }


    // 3. Complex objects via MethodSource

    static Stream<String> generateInvalidEmails() {

        return Stream.of("plainaddress", "missing@domain", "@nodata.org", "spaces in@mail.com");

    }


    @ParameterizedTest

    @MethodSource("generateInvalidEmails")

    void shouldRejectMalformedEmailAddresses(String invalidEmail) {

        assertFalse(invalidEmail.matches("^[A-Za-z0-9+_.-]+@(.+)$"));

    }

}


6. Test Isolation and Test Doubles with Mockito

In enterprise applications, classes rarely operate in a vacuum. A PaymentService depends on a PaymentGatewayClient, an AccountRepository, and an AuditLogEmitter.

If you test PaymentService against real implementations:

 * The test runs slowly over the network.

 * You charge actual credit cards.

 * If the external database goes down, your unit test fails even though your business logic is correct.

Real Unit Testing:

[ Class Under Test ] ──► [ Fake / Mock Dependency ] <-- Fast, In-Memory, Predictable


The Types of Test Doubles:

 * Dummy: Objects passed around but never used (e.g., filling parameter lists).

 * Fake: Working implementations that take shortcuts (e.g., an in-memory H2 or hash-map repository).

 * Stub: Provides pre-programmed answers to method calls made during the test.

 * Mock: Pre-programmed with expectations, records method interactions, and allows verification that specific methods were invoked.

Mockito is the de facto standard mocking library for Java.

7. Mockito in Practice: Mocking, Stubbing, and Verifying

To use Mockito with JUnit 5, annotate your test class with @ExtendWith(MockitoExtension.class).

Core Annotations:

 * @Mock: Creates a synthetic surrogate instance of an interface or class.

 * @InjectMocks: Instantiates your concrete Class Under Test and automatically injects all declared @Mock fields into its constructor.

import org.junit.jupiter.api.Test;

import org.junit.jupiter.api.extension.ExtendWith;

import org.mockito.InjectMocks;

import org.mockito.Mock;

import org.mockito.junit.jupiter.MockitoExtension;


import java.util.Optional;


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

import static org.mockito.Mockito.*;


// 1. Interfaces representing external boundaries

interface CustomerRepository {

    Optional<Customer> findById(String id);

    void save(Customer customer);

}


interface NotificationService {

    void sendOrderConfirmation(String email, String orderId);

}


record Customer(String id, String email, double accountBalance) {}


// 2. Class Under Test

class CheckoutEngine {

    private final CustomerRepository customerRepo;

    private final NotificationService notificationService;


    public CheckoutEngine(CustomerRepository customerRepo, NotificationService notificationService) {

        this.customerRepo = customerRepo;

        this.notificationService = notificationService;

    }


    public boolean processCheckout(String customerId, String orderId, double totalAmount) {

        Customer customer = customerRepo.findById(customerId)

            .orElseThrow(() -> new IllegalArgumentException("Customer does not exist"));


        if (customer.accountBalance() < totalAmount) {

            return false; // Insufficient funds

        }


        // Deduct balance and persist

        Customer updated = new Customer(

            customer.id(), 

            customer.email(), 

            customer.accountBalance() - totalAmount

        );

        customerRepo.save(updated);


        // Notify client

        notificationService.sendOrderConfirmation(customer.email(), orderId);

        return true;

    }

}


// 3. The Isolated Unit Test Suite

@ExtendWith(MockitoExtension.class)

class CheckoutEngineTest {


    @Mock

    private CustomerRepository customerRepository;


    @Mock

    private NotificationService notificationService;


    @InjectMocks

    private CheckoutEngine checkoutEngine; // Injects the two mocks into constructor


    @Test

    void shouldSuccessfullyProcessCheckoutWhenFundsSufficient() {

        // GIVEN (Arrange)

        String customerId = "CUST-9901";

        String orderId = "ORD-4401";

        Customer mockCustomer = new Customer(customerId, "alex@corp.internal", 500.0);


        // Stubbing behavior: when findById is called with this ID, return our mock customer

        when(customerRepository.findById(customerId)).thenReturn(Optional.of(mockCustomer));


        // WHEN (Act)

        boolean result = checkoutEngine.processCheckout(customerId, orderId, 150.0);


        // THEN (Assert)

        assertTrue(result, "Expected checkout to complete successfully");


        // Verify that save() was called with a customer whose balance was reduced by 150.0

        verify(customerRepository, times(1)).save(new Customer(customerId, "alex@corp.internal", 350.0));


        // Verify notification was dispatched to the customer's email

        verify(notificationService, times(1)).sendOrderConfirmation("alex@corp.internal", orderId);

    }


    @Test

    void shouldAbortCheckoutAndSkipNotificationWhenBalanceInsufficient() {

        // GIVEN (Arrange)

        String customerId = "CUST-9902";

        String orderId = "ORD-4402";

        Customer lowBalanceCustomer = new Customer(customerId, "broke@corp.internal", 25.0);


        when(customerRepository.findById(customerId)).thenReturn(Optional.of(lowBalanceCustomer));


        // WHEN (Act)

        boolean outcome = checkoutEngine.processCheckout(customerId, orderId, 100.0);


        // THEN (Assert)

        assertFalse(outcome, "Checkout should fail due to insufficient funds");


        // Verify side effects: Repo should NEVER have been saved

        verify(customerRepository, never()).save(any(Customer.class));


        // Notification service should NEVER have been invoked

        verifyNoInteractions(notificationService);

    }

}


8. Argument Captors: Deep Inspection of Outgoing Arguments

Sometimes simple equality checking in verify() isn't enough. What if a method creates a complex internal object that you need to inspect?

Use Mockito's ArgumentCaptor:

import org.mockito.ArgumentCaptor;

import org.mockito.Captor;


@ExtendWith(MockitoExtension.class)

class ArgumentCaptorDemoTest {


    @Mock

    private CustomerRepository customerRepository;


    @Mock

    private NotificationService notificationService;


    @InjectMocks

    private CheckoutEngine checkoutEngine;


    @Captor

    private ArgumentCaptor<Customer> customerCaptor;


    @Test

    void inspectsCapturedCustomerObject() {

        Customer initial = new Customer("CUST-1", "user@test.org", 200.0);

        when(customerRepository.findById("CUST-1")).thenReturn(Optional.of(initial));


        checkoutEngine.processCheckout("CUST-1", "ORD-1", 50.0);


        // Capture the argument passed to save()

        verify(customerRepository).save(customerCaptor.capture());


        Customer savedCustomer = customerCaptor.getValue();

        assertEquals(150.0, savedCustomer.accountBalance());

        assertEquals("user@test.org", savedCustomer.email());

    }

}


9. Best Practices for Production Test Suites

 * Follow the AAA Pattern (Arrange, Act, Assert): Visually structure every test into three clean phases. Keep tests short and readable.

 * Never Mock What You Don't Own: Don't mock standard library classes like ArrayList, String, or third-party libraries you don't control. Mock your architectural boundaries and interfaces.

 * One Logical Assertion Concept per Test: A single test should fail for only one logical reason. Avoid testing five different business scenarios inside one giant @Test method.

 * Deterministic Execution: Tests must produce the exact same outcome regardless of the order in which they run, the host OS, or the timezone of the server.

 * Fast Feedback Loop: Unit tests across a codebase of 5,000 classes should complete in seconds. If a test suite takes 20 minutes, developers stop running tests locally before pushing code.

📝 Chapter Summary

 * Unit Tests validate isolated units of business logic in-memory without reaching across disk, database, or network boundaries.

 * JUnit 5 (Jupiter) separates the runner platform from the programming model, providing modern lifecycle hooks (@BeforeEach, @AfterEach, @BeforeAll, @AfterAll).

 * Use assertAll() to evaluate groups of assertions without failing fast on the first error, and assertThrows() to assert defensive exception handling.

 * Parameterized Tests (@ParameterizedTest) eliminate duplicate test methods by running identical logic across datasets supplied via @ValueSource, @CsvSource, or @MethodSource.

 * Mockito isolates classes under test by providing synthetic doubles (@Mock):

   * when(...).thenReturn(...) stubs method return values.

   * verify(mock, times(n)) validates that expected side-effect interactions occurred.

   * never() and verifyNoInteractions() prove unwanted actions did not occur.

   * ArgumentCaptor inspects internal domain models passed across component boundaries.