Skip to main content
Java intermediate Lesson 44 of 58

Unit Testing with JUnit 5 and Mockito

Learn to write effective unit tests in Java using JUnit 5 annotations and assertions, and mock dependencies with Mockito.

Testing verifies that your code does what you think it does — and keeps doing it as you change things. Without tests, every refactor is a gamble. With a good test suite, you can change code confidently because failures tell you exactly what broke. JUnit 5 is the standard test framework for Java; Mockito is the standard mocking library for replacing real dependencies with controllable fakes.

Setup

Both libraries need to be on the test classpath. The Surefire plugin must be version 3.x to discover and run JUnit 5 tests — the older default version only supports JUnit 4.

Maven pom.xml:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>
</dependency>

<!-- Surefire must be 3.x to run JUnit 5 -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
</plugin>

JUnit 5 Basics

JUnit 5 uses annotations to declare test methods and lifecycle hooks. Each test method gets a fresh instance of the test class by default, so state from one test cannot bleed into another. Lifecycle annotations let you set up shared resources before tests run and clean up after they finish.

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

class CalculatorTest {

    private Calculator calc;

    @BeforeEach
    void setUp() {
        calc = new Calculator();  // fresh instance before each test — no shared state
    }

    @AfterEach
    void tearDown() {
        // runs after each test — close resources, reset state
    }

    @BeforeAll
    static void setUpClass() {
        // runs once before all tests in this class — good for expensive setup like DB connections
    }

    @AfterAll
    static void tearDownClass() {
        // runs once after all tests — clean up shared resources
    }

    @Test
    void add_twoPositiveNumbers_returnsSum() {
        int result = calc.add(3, 4);
        assertEquals(7, result);
    }

    @Test
    void divide_byZero_throwsArithmeticException() {
        assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
    }

    @Test
    @Disabled("not implemented yet")
    void subtract_willBeImplemented() { }

    @Test
    @DisplayName("Multiplication of negative numbers")
    void multiply_negativeNumbers() {
        assertEquals(6, calc.multiply(-2, -3));
    }
}

Assertions

JUnit 5’s assertion methods cover equality, booleans, nulls, exceptions, and collections. assertAll is particularly useful — it runs all assertions even if the first one fails, so you see every problem in one run rather than fixing failures one at a time.

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

// Equality — use delta for doubles to avoid floating-point precision issues
assertEquals(42, result);
assertEquals(3.14, result, 0.001);
assertEquals("hello", actual);

// Boolean
assertTrue(list.isEmpty());
assertFalse(user.isAdmin());

// Null checks
assertNull(result);
assertNotNull(result);

// Reference equality (same object, not just equal)
assertSame(expected, actual);

// Arrays and collections
assertArrayEquals(new int[]{1, 2, 3}, actual);
assertIterableEquals(List.of("a", "b"), actualList);

// Exception assertions — also captures the exception for message inspection
Exception ex = assertThrows(IllegalArgumentException.class,
    () -> service.create(null));
assertEquals("Name cannot be null", ex.getMessage());

// Verify no exception is thrown
assertDoesNotThrow(() -> service.process(validInput));

// assertAll — runs every assertion; reports all failures at once
assertAll("user fields",
    () -> assertEquals("Alice", user.getName()),
    () -> assertEquals(25, user.getAge()),
    () -> assertNotNull(user.getEmail())
);

// Custom failure message — use a lambda to avoid building it when test passes
assertEquals(expected, actual, () -> "Expensive message built only on failure: " + computeDetails());

Parameterized Tests

Parameterized tests run the same test logic with multiple different inputs. They replace copy-pasted test methods and make it trivially easy to add new cases. @ValueSource handles simple single-value cases; @CsvSource handles multi-argument cases inline; @MethodSource handles complex objects or large datasets.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

class FizzBuzzTest {

    // @ValueSource feeds one value at a time from the array
    @ParameterizedTest
    @ValueSource(ints = {3, 6, 9, 12})
    void divisibleBy3_returnsFizz(int n) {
        assertEquals("Fizz", FizzBuzz.compute(n));
    }

    // @CsvSource provides rows of comma-separated values — parsed into method arguments
    @ParameterizedTest
    @CsvSource({
        "1,  One",
        "2,  Two",
        "10, Ten"
    })
    void compute_returnsExpected(int input, String expected) {
        assertEquals(expected, NumberWords.convert(input));
    }

    // @MethodSource references a static factory method for complex arguments
    @ParameterizedTest
    @MethodSource("emailProvider")
    void isValidEmail_variousInputs(String email, boolean expected) {
        assertEquals(expected, EmailValidator.isValid(email));
    }

    static Stream<Arguments> emailProvider() {
        return Stream.of(
            Arguments.of("[email protected]", true),
            Arguments.of("not-an-email",      false),
            Arguments.of("",                  false),
            Arguments.of("[email protected]",             true)
        );
    }
}

Mockito — Mocking Dependencies

Real dependencies like database repositories, HTTP clients, and email services make tests slow, flaky, and hard to set up. Mockito replaces them with controllable fakes. You define exactly what the mock returns for specific inputs, then verify afterwards that it was called correctly.

Setting Up Mocks

@ExtendWith(MockitoExtension.class) activates Mockito’s JUnit 5 integration. @Mock creates a mock for a field; @InjectMocks creates the class under test and automatically injects the mocks into its constructor or fields.

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.mockito.BDDMockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;  // Mockito creates a fake — no real DB needed

    @Mock
    private EmailService emailService;      // another fake

    @InjectMocks
    private UserService userService;        // Mockito injects the mocks into this
}

Stubbing — Define What Mocks Return

Stubbing sets up the mock’s behavior before the code under test runs. when(...).thenReturn(...) is the most common pattern. Argument matchers like anyString() and any(Class) let you stub broadly when the exact argument doesn’t matter.

@Test
void getUser_existingId_returnsUser() {
    // Given — set up what the mock returns for this specific call
    User alice = new User(1L, "Alice", "[email protected]");
    when(userRepository.findById(1L)).thenReturn(Optional.of(alice));

    // When — call the method under test
    User result = userService.getUser(1L);

    // Then — verify the result
    assertEquals("Alice", result.getName());
}

@Test
void getUser_nonExistentId_throwsNotFoundException() {
    when(userRepository.findById(99L)).thenReturn(Optional.empty());

    assertThrows(UserNotFoundException.class, () -> userService.getUser(99L));
}

// Argument matchers — stub broadly when exact values don't matter
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));

// Stub void methods
doNothing().when(emailService).sendWelcome(any());
doThrow(new EmailException("SMTP error")).when(emailService).sendWelcome(any());

Verification — Assert That Methods Were Called

After running the code under test, verify() asserts that the mock was called with the right arguments the right number of times. This is how you test side effects — like “was an email sent?” — that don’t appear in the return value.

@Test
void createUser_sendsWelcomeEmail() {
    when(userRepository.save(any())).thenAnswer(inv -> {
        User u = inv.getArgument(0);
        return new User(1L, u.getName(), u.getEmail());
    });

    userService.createUser("Alice", "[email protected]");

    // Verify the email was sent exactly once with the right address
    verify(emailService, times(1)).sendWelcome("[email protected]");

    // Verify the repository was called
    verify(userRepository).save(any(User.class));

    // Verify a method was never called
    verify(emailService, never()).sendPasswordReset(any());
}

// ArgumentCaptor — inspect the exact object passed to a mock
@Test
void createUser_savesCorrectEmail() {
    ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
    when(userRepository.save(captor.capture())).thenAnswer(inv -> inv.getArgument(0));

    userService.createUser("Bob", "[email protected]");

    User saved = captor.getValue();
    assertEquals("[email protected]", saved.getEmail());
}

A Complete Test Class Example

A well-structured test class follows the Given/When/Then pattern in each test: set up the scenario, call the code under test, assert the outcome. Each test covers one specific scenario, including failure paths.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock  private OrderRepository  orderRepository;
    @Mock  private PaymentGateway   paymentGateway;
    @Mock  private InventoryService inventoryService;
    @InjectMocks private OrderService orderService;

    @Test
    void placeOrder_validItems_returnsConfirmation() {
        // Given
        List<OrderItem> items = List.of(new OrderItem("SKU-1", 2, 9.99));
        when(inventoryService.isAvailable("SKU-1", 2)).thenReturn(true);
        when(paymentGateway.charge("card-123", 19.98)).thenReturn("PAY-001");
        when(orderRepository.save(any())).thenAnswer(inv -> {
            Order o = inv.getArgument(0);
            return new Order(UUID.randomUUID().toString(), o.getItems(), "CONFIRMED");
        });

        // When
        OrderConfirmation result = orderService.placeOrder("card-123", items);

        // Then
        assertNotNull(result.orderId());
        assertEquals("CONFIRMED", result.status());
        verify(inventoryService).reserve("SKU-1", 2);
        verify(paymentGateway).charge("card-123", 19.98);
    }

    @Test
    void placeOrder_itemOutOfStock_throwsOutOfStockException() {
        when(inventoryService.isAvailable("SKU-2", 1)).thenReturn(false);

        List<OrderItem> items = List.of(new OrderItem("SKU-2", 1, 49.99));
        assertThrows(OutOfStockException.class,
            () -> orderService.placeOrder("card-123", items));

        // Payment must never be charged if inventory check fails
        verify(paymentGateway, never()).charge(any(), anyDouble());
    }

    @Test
    void placeOrder_paymentFails_rollsBackInventory() {
        when(inventoryService.isAvailable("SKU-1", 1)).thenReturn(true);
        when(paymentGateway.charge(any(), anyDouble()))
            .thenThrow(new PaymentException("Card declined"));

        assertThrows(PaymentException.class,
            () -> orderService.placeOrder("bad-card", List.of(new OrderItem("SKU-1", 1, 9.99))));

        // Verify inventory was released on payment failure — the rollback happened
        verify(inventoryService).release("SKU-1", 1);
    }
}

Test Naming Convention

Good test names make failing tests self-documenting — you know exactly what broke without reading the test body. The methodName_scenario_expectedBehaviour convention captures all three pieces of information.

methodName_scenario_expectedBehaviour

add_twoPositiveNumbers_returnsSum
divide_byZero_throwsArithmeticException
createUser_duplicateEmail_throwsConflictException
getUser_nonExistentId_returnsEmpty

Running Tests

# Maven
mvn test
mvn test -Dtest=UserServiceTest            # specific test class
mvn test -Dtest=UserServiceTest#getUser*   # specific test methods

# Gradle
./gradlew test
./gradlew test --tests "com.example.UserServiceTest"
./gradlew test --tests "*.UserServiceTest.getUser*"

Frequently Asked Questions

What is the difference between a unit test and an integration test?
A unit test tests a single class in isolation — all dependencies are replaced with mocks or stubs so the test is fast and deterministic. An integration test tests multiple components together (e.g. a service + real database) to verify they work as a system. Unit tests run in milliseconds; integration tests may take seconds.
What is mocking?
Mocking replaces a real dependency (e.g. a database repository or HTTP client) with a fake object you control. You define what the mock returns when called, which lets you test your class in isolation without needing a real database, network, or external service.
When should I NOT mock something?
Don't mock value objects, simple data containers, or things you own that have no I/O. Don't mock the class under test. Don't mock things you don't own (like java.util.List) — use the real thing. The rule of thumb: mock at architectural boundaries (repositories, HTTP clients, email senders).