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*"