JUnit 5 and Mockito

Testing isn’t just a box to check; it’s a vital practice that guarantees code quality, slashes bugs, and makes your software a dream to maintain. In the Java world, JUnit 5 and Mockito are the dynamic duo for crafting powerful and effective unit tests.

This guide will walk you through everything you need to know, from the basics to advanced techniques, with practical examples to turn you into a testing pro. 🧪


What’s a Good Unit Test, Anyway?

Before we dive in, let’s define our goal. A great unit test isolates a single piece of code—a method or a class, often called the System Under Test (SUT)—and verifies its behavior. We want our tests to follow the FIRST principles:

  • Fast: They should run in milliseconds.

  • Isolated: Each test should be independent of others.

  • Repeatable: They must produce the same result every time.

  • Self-Validating: The test should automatically detect success or failure.

  • Timely: They should be written alongside the production code.

This is where JUnit 5 and Mockito shine. JUnit provides the structure and assertions, while Mockito lets us isolate our SUT from its dependencies.


The Foundation: JUnit 5 Fundamentals

JUnit 5 is the latest evolution of Java’s most popular testing framework. It’s a modular and modern tool for writing and running tests.

Basic Test Structure

Every test class is a simple Java class with methods annotated to guide the test runner.

Java

import org.junit.jupiter.api.*;

class MyFirstTest {

    // Runs once before any tests in this class
    @BeforeAll
    static void setupAll() {
        System.out.println("Setting up the test suite...");
    }

    // Runs before each individual @Test method
    @BeforeEach
    void setup() {
        System.out.println("Preparing for a new test...");
    }

    @Test
    @DisplayName("A simple test to ensure true is true ✅")
    void testSomethingSimple() {
        Assertions.assertTrue(true, "This should always pass!");
    }

    // Runs after each individual @Test method
    @AfterEach
    void tearDown() {
        System.out.println("Cleaning up after a test.");
    }

    // Runs once after all tests in this class have completed
    @AfterAll
    static void tearDownAll() {
        System.out.println("Test suite finished.");
    }
}

Key JUnit 5 Assertions

Assertions are static methods that check if a condition is true. If not, they throw an AssertionFailedError, failing the test. JUnit 5’s Assertions class is packed with useful methods.

Java

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; // Static import for readability

class AssertionsDemoTest {
    @Test
    void assertionsDemo() {
        // Basic assertions
        assertEquals(4, 2 + 2);
        assertNotEquals(5, 2 + 2, "2+2 should not be 5");
        assertTrue(5 > 3);
        assertFalse(1 > 2);
        assertNull(null);
        assertNotNull("Hello");

        // Grouped assertions: all assertions are run, and all failures are reported together.
        assertAll("calculator operations",
            () -> assertEquals(4, 2 * 2),
            () -> assertEquals("hello", "HELLO".toLowerCase()),
            () -> assertEquals(1, 5 - 4)
        );

        // Exception testing: verify that a specific exception is thrown.
        Exception exception = assertThrows(
            ArithmeticException.class,
            () -> { int i = 1 / 0; },
            "Dividing by zero should throw ArithmeticException"
        );
        assertEquals("/ by zero", exception.getMessage());
    }
}

Isolating Your Code: Enter Mockito

A unit test should only test the SUT, not its dependencies (like databases, APIs, or other services). But what if your UserService needs a UserRepository to function? You can’t connect to a real database; that would be slow and brittle!

This is the problem Mockito solves. It lets you create test doubles—fake versions of these dependencies—so you can dictate their behavior and isolate your SUT.

Getting Started with Mockito

First, add the necessary Maven dependencies to your pom.xml. It’s a good practice to use the latest stable versions.

XML

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.12.0</version> <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.12.0</version> <scope>test</scope>
</dependency>

Stubs vs. Mocks vs. Spies: A Simple Analogy

Mockito lets you create three main types of test doubles. Let’s think of them as actors in a movie:

  • Stub: A stand-in actor who only knows one pre-scripted line. If your code asks it for user data, it returns a fixed, hardcoded user. It has no memory and doesn’t care how it’s used.

  • Mock: A smart actor you can give instructions to (when this happens, say that). After the scene, you can interrogate it (verify the director told you to say your line exactly once with a specific emphasis). This is great for behavior verification.

  • Spy: The real actor, but you’ve given them a secret earpiece. By default, they’ll perform as they normally would (calling real methods), but you can feed them a specific line for one critical scene (doReturn().when()).

In Mockito terminology, “stubbing” refers to configuring a mock to return a specific value (e.g., when(...).thenReturn(...)).


Seamless Integration: Mockito with JUnit 5

The cleanest way to use Mockito in a JUnit 5 test is with the MockitoExtension. It automatically initializes mocks, saving you from boilerplate setup code.

The Power of Annotations: @Mock and @InjectMocks

  • @Mock: Creates a mock object for a class or interface.

  • @InjectMocks: Creates an instance of your SUT and tries to inject any fields annotated with @Mock or @Spy into it (via constructor, setter, or field injection).

Let’s see it in action. Imagine a NotificationService that depends on an EmailClient.

System Under Test (SUT):

Java

// SUT: The class we want to test
class NotificationService {
    private final EmailClient emailClient;

    public NotificationService(EmailClient emailClient) {
        this.emailClient = emailClient;
    }

    public boolean sendWelcomeEmail(String email) {
        if (email == null || !email.contains("@")) {
            return false;
        }
        String subject = "Welcome!";
        String body = "Thanks for joining us.";
        return emailClient.send(email, subject, body);
    }
}

// Dependency: An interface we don't want to use the real implementation of
interface EmailClient {
    boolean send(String to, String subject, String body);
}

The Test Class:

Java

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 static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

// 1. Enable the Mockito extension
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {

    // 2. Create a mock for the dependency
    @Mock
    private EmailClient mockEmailClient;

    // 3. Inject the mock into our SUT
    @InjectMocks
    private NotificationService notificationService;

    @Test
    void sendWelcomeEmail_ShouldSucceed_ForValidEmail() {
        // Arrange: Configure the mock's behavior (stubbing)
        when(mockEmailClient.send(
            eq("test@example.com"),
            anyString(),
            anyString()
        )).thenReturn(true);

        // Act: Call the method we are testing
        boolean result = notificationService.sendWelcomeEmail("test@example.com");

        // Assert: Check the outcome
        assertTrue(result);

        // Verify: Ensure the mock was called correctly
        verify(mockEmailClient, times(1)).send(
            "test@example.com",
            "Welcome!",
            "Thanks for joining us."
        );
    }

    @Test
    void sendWelcomeEmail_ShouldFail_ForInvalidEmail() {
        // Act
        boolean result = notificationService.sendWelcomeEmail("invalid-email");

        // Assert
        assertFalse(result);

        // Verify that the mock was never called
        verify(mockEmailClient, never()).send(anyString(), anyString(), anyString());
    }
}

Note on Manual Initialization: If you can’t use the extension, you can manually initialize mocks using MockitoAnnotations.openMocks(this) in your @BeforeEach method. However, @ExtendWith is the preferred modern approach.


The Advanced Mockito Arsenal 🧰

Argument Matchers

Sometimes you don’t care about the exact value of an argument. Argument matchers give you flexibility.

  • anyString()anyInt()any(): Matches any value of that type.

  • eq(value): Used when you mix matchers with literal values.

  • argThat(lambda): Matches a custom condition.

Java

// Stubbing with a matcher
when(mockList.add(argThat(s -> s.length() > 5))).thenReturn(true);

// Verification with a matcher
verify(mockList).get(anyInt());

Argument Captors

What if you need to inspect an argument that was passed to your mock? ArgumentCaptor lets you capture it for later assertions.

Java

@Test
void argumentCaptorDemo() {
    // Arrange
    ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);

    // Act
    mockList.add("one");
    mockList.add("two");

    // Capture and Verify
    verify(mockList, times(2)).add(captor.capture());

    // Assert on the captured values
    List<String> capturedValues = captor.getAllValues();
    assertEquals("one", capturedValues.get(0));
    assertEquals("two", capturedValues.get(1));
}

The doReturn() vs. when() Dilemma

When testing spies or methods with side effects, when(spy.doSomething()).thenReturn(...) can be dangerous because it calls the real method first.

To be safe, use the doReturn(...).when(...) pattern. It stubs the method without ever calling the real implementation.

Java

// Suppose this is a real list we want to spy on
List<String> realList = new ArrayList<>();
List<String> spyList = Mockito.spy(realList);

// DANGEROUS: This calls spyList.get(0) which throws an IndexOutOfBoundsException!
// when(spyList.get(0)).thenReturn("first");

// SAFE: This stubs the method without calling it.
doReturn("first").when(spyList).get(0);

assertEquals("first", spyList.get(0));

Mocking Static Methods

Since Mockito 3.4.0, you can mock static methods using a try-with-resources block. This ensures the mock is only active within that block.

Java

@Test
void staticMockDemo() {
    assertEquals(4, Integer.max(4, 1)); // Original behavior

    try (MockedStatic<Integer> mockedInt = Mockito.mockStatic(Integer.class)) {
        // Stub the static method
        mockedInt.when(() -> Integer.max(anyInt(), anyInt())).thenReturn(999);

        // Assert the mocked behavior
        assertEquals(999, Integer.max(4, 1));

        // Verify the static method call
        mockedInt.verify(() -> Integer.max(4, 1));
    }

    // Behavior is restored outside the try block
    assertEquals(4, Integer.max(4, 1));
}

Writing Smarter Tests with Parameterized Tests

Tired of writing the same test for different inputs? JUnit 5’s parameterized tests let you run one test method multiple times with different arguments, keeping your code DRY (Don’t Repeat Yourself).

Java

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

class ParameterizedDemoTest {

    @ParameterizedTest
    @ValueSource(ints = {1, 3, 5, -3, 15})
    void isOdd_ShouldReturnTrueForOddNumbers(int number) {
        assertTrue(NumberUtils.isOdd(number));
    }

    @ParameterizedTest
    @CsvSource({
        "2,  3,  5",
        "10, 20, 30",
        "0,  0,  0"
    })
    void addNumbers_ShouldReturnCorrectSum(int a, int b, int expected) {
        assertEquals(expected, Calculator.add(a, b));
    }
}

Best Practices for Robust & Maintainable Tests

  1. Embrace Dependency Injection: Design your classes to receive dependencies via their constructor. This makes them incredibly easy to test, as you can simply pass in mocks.

  2. Test Behavior, Not Implementation: Focus on the “what,” not the “how.” A test for sendWelcomeEmail should verify that an email was sent, not that a private helper method was called.

  3. Avoid Over-Mocking: Only mock immediate dependencies. If you find yourself mocking a mock that returns another mock, your design might be too tightly coupled.

  4. One Logical Assert Per Test: Each test method should verify a single concept or behavior. This makes failures easier to diagnose.

  5. Use Meaningful Verification: Verify crucial interactions (verify(emailClient).send(...)), but avoid overly strict checks like verifyNoMoreInteractions(), which can make tests brittle.


JUnit 4 vs. JUnit 5: The Glow-Up ✨

You might have seen older Java projects using JUnit 4. While it was a fantastic tool for its time, JUnit 5 is a complete, from-the-ground-up redesign. Think of it less as an update and more as a whole new, modular, and more powerful platform.

Here’s a quick comparison to see what’s changed for the better:

Feature/AspectThe Classic Way (JUnit 4) 👴The Modern Way (JUnit 5) 🚀
ArchitectureMonolithic (everything in one jar)Modular. It has the JUnit Platform to run tests, JUnit Jupiter for new tests, and JUnit Vintage to run old JUnit 4 tests.
Basic Annotations@Before@After@BeforeClass@AfterClassMore descriptive names@BeforeEach@AfterEach@BeforeAll@AfterAll. Much clearer!
Exception Testing@Test(expected = SomeException.class)assertThrows(). This is a huge improvement because you can inspect the exception after it’s caught (e.g., check its message).
Test Extensibility@RunWith(MockitoJUnitRunner.class)@ExtendWith(MockitoExtension.class). The new extension model is more powerful and allows multiple extensions to be chained.
Assertionsorg.junit.Assert.assertEquals(...)org.junit.jupiter.api.Assertions.assertEquals(...). Plus, new powerful assertions like assertAll() for grouping.
Disabling Tests@Ignore@Disabled. You can also add a reason, like @Disabled("Ticket-123: Waiting for API fix").
Test NamingRelied on long method names like test_ShouldFail_WhenEmailIsInvalid()@DisplayName("Test should fail when email is invalid"). You can use spaces, emojis, and full sentences for much more readable test reports! ✅

The Bottom Line: JUnit 5 is the standard for modern Java testing. Its modularity, descriptive annotations, and powerful features like parameterized tests and display names make writing and understanding tests a much better experience.


(Insert this section inside “The Advanced Mockito Arsenal 🧰”)

The “Break Glass” Rule: Testing Private & Final Methods 🚨

Generally, you should only test the public API of your classes. If you feel a strong urge to test a private method, it’s often a “code smell” suggesting that the private method has too much logic and might belong in its own class, where it would become public and easily testable.

However, in rare cases (like dealing with legacy code), you might need to.

Testing private Methods (Use with Caution!)

This isn’t a job for Mockito, but for Reflection. It involves programmatically accessing parts of a class that you normally can’t. It’s powerful but makes tests brittle, as they can break if you refactor the private method’s name or parameters.

Java

@Test
void testPrivateMethod_withReflection() throws Exception {
    // Arrange
    MyService service = new MyService();
    // Get the method from the class, even if it's private
    Method privateMethod = MyService.class.getDeclaredMethod("createMessage", String.class);
    // Make it accessible
    privateMethod.setAccessible(true);

    // Act
    String result = (String) privateMethod.invoke(service, "World");

    // Assert
    assertEquals("Hello, World!", result);
}

Again, your first choice should always be to refactor for better testability!

Mocking final Classes & Methods

Previously, Mockito couldn’t touch final classes or methods. But now, it can! You just need to enable an optional feature.

  1. Create this file path in your test resources: src/test/resources/mockito-extensions/

  2. Inside that folder, create a file named org.mockito.plugins.MockMaker.

  3. Add this single line of text to the file:

    mock-maker-inline

That’s it! Mockito will now be able to mock final classes and methods, just like any other class.

Java

// Assuming FinalClass is a final class
@Test
void testFinalClassMocking() {
    FinalClass mock = Mockito.mock(FinalClass.class);

    when(mock.finalMethod()).thenReturn("mocked result");

    assertEquals("mocked result", mock.finalMethod());
    verify(mock).finalMethod();
}

(Insert this section after “Best Practices for Robust & Maintainable Tests”)

Integration Testing: Making Sure the Pieces Fit 🧩

So far, we’ve focused on unit tests, where we isolate a single class. But what about testing how multiple components work together? That’s where integration testing comes in.

  • Unit Test: Does the OrderService logic work correctly if the DatabaseRepository behaves as expected (mocked)?

  • Integration Test: Does the OrderService correctly save an order to a real (or in-memory) DatabaseRepository?

You don’t need to test every component together. A common strategy is to test adjacent layers, like the Service layer with the Repository layer, while still mocking things that are truly external, like a third-party payment API.

Example: Testing a Service with an In-Memory Repository

Let’s imagine an OrderProcessor that relies on an InventoryService to check stock before charging a card via a PaymentGateway. For our integration test, we’ll use a real (but simple) InventoryService and mock the external PaymentGateway.

Java

// --- The Classes ---

// SUT: The main class for our test
class OrderProcessor {
    private final InventoryService inventoryService;
    private final PaymentGateway paymentGateway;
    // Constructor...
    public boolean processOrder(String item, int quantity) {
        if (inventoryService.isStockAvailable(item, quantity)) {
            // some logic...
            return paymentGateway.charge("credit-card-details");
        }
        return false;
    }
}

// Internal Dependency (Real Implementation)
class InMemoryInventoryService implements InventoryService {
    private final Map<String, Integer> stock = new HashMap<>();
    public void addStock(String item, int quantity) { stock.put(item, quantity); }
    public boolean isStockAvailable(String item, int quantity) { /* ... */ }
}

// External Dependency (Interface to be Mocked)
interface PaymentGateway {
    boolean charge(String cardDetails);
}

// --- The Integration Test ---

@ExtendWith(MockitoExtension.class)
class OrderProcessorIntegrationTest {

    // 1. Mock the external dependency
    @Mock
    private PaymentGateway mockPaymentGateway;

    // 2. Use a REAL instance of our internal dependency
    private InventoryService inventoryService = new InMemoryInventoryService();

    // 3. Inject mocks and real instances into the SUT
    @InjectMocks
    private OrderProcessor orderProcessor;

    @BeforeEach
    void setup() {
        // We need to manually set the real service if constructor injection
        // can't find a mock for it. A common approach is to initialize in setup.
        orderProcessor = new OrderProcessor(inventoryService, mockPaymentGateway);

        // Prepare the state of our real service
        ((InMemoryInventoryService) inventoryService).addStock("Laptop", 5);
    }

    @Test
    @DisplayName("Processing an order should succeed when stock is available and payment is good")
    void processOrder_ShouldSucceed() {
        // Arrange
        when(mockPaymentGateway.charge(anyString())).thenReturn(true);

        // Act
        boolean result = orderProcessor.processOrder("Laptop", 1);

        // Assert
        assertTrue(result);

        // Verify the external mock was called
        verify(mockPaymentGateway).charge(anyString());
    }
}

This test gives you confidence that OrderProcessor and InventoryService are integrated correctly, without the slowness or unreliability of calling a real, external payment API. Unit and integration tests work together to build a robust safety net for your application.


Conclusion

JUnit 5 and Mockito are a powerhouse for modern Java development. By mastering them, you can build a comprehensive safety net for your code, enabling you to refactor and add features with confidence.

Remember the key ideas:

  • Use JUnit 5 for test structure, execution, and assertions.

  • Use Mockito to create test doubles that isolate your code from its dependencies.

  • Follow best practices like dependency injection to write tests that are clean, fast, and maintainable.

Post a Comment

Previous Post Next Post