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
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.
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.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.
One Logical Assert Per Test: Each test method should verify a single concept or behavior. This makes failures easier to diagnose.
Use Meaningful Verification: Verify crucial interactions (
verify(emailClient).send(...)
), but avoid overly strict checks likeverifyNoMoreInteractions()
, 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/Aspect | The Classic Way (JUnit 4) 👴 | The Modern Way (JUnit 5) 🚀 |
---|---|---|
Architecture | Monolithic (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 , @AfterClass | More 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. |
Assertions | org.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 Naming | Relied 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.
Create this file path in your test resources:
src/test/resources/mockito-extensions/
Inside that folder, create a file named
org.mockito.plugins.MockMaker
.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 theDatabaseRepository
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