Skip to main content

Testing: JUnit 5, Mockito, and AssertJ

Good tests give you confidence to refactor, document behaviour, and catch regressions early. This page covers the modern Java testing stack: JUnit 5 for test structure, Mockito for mocking dependencies, and AssertJ for readable assertions.

Maven dependencies

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.0</version>
<scope>test</scope>
</dependency>

JUnit 5 basics

Test lifecycle

import org.junit.jupiter.api.*;

class UserServiceTest {

@BeforeAll
static void setupOnce() {
// runs once before all tests (static)
}

@BeforeEach
void setup() {
// runs before each test
}

@Test
void shouldCreateUser() {
// test logic
}

@Test
@DisplayName("User creation fails with blank name")
void shouldFailWithBlankName() {
// test logic
}

@AfterEach
void teardown() {
// runs after each test
}

@AfterAll
static void cleanupOnce() {
// runs once after all tests (static)
}
}

Assertions (JUnit built-in)

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

@Test
void builtInAssertions() {
assertEquals(4, 2 + 2);
assertNotEquals("foo", "bar");
assertTrue(list.isEmpty());
assertFalse(list.contains("x"));
assertNull(result);
assertNotNull(user);
assertThrows(IllegalArgumentException.class, () -> service.create(null));
assertTimeout(Duration.ofSeconds(2), () -> slowOperation());

// Grouped assertions (all run, even if some fail)
assertAll(
() -> assertEquals("Alice", user.name()),
() -> assertEquals(30, user.age()),
() -> assertNotNull(user.email())
);
}

@Nested tests

Group related tests for better organisation and shared setup:

class OrderServiceTest {

@Nested
@DisplayName("When order is empty")
class EmptyOrder {

private Order order;

@BeforeEach
void setup() {
order = new Order();
}

@Test
void totalShouldBeZero() {
assertEquals(BigDecimal.ZERO, order.total());
}

@Test
void shouldHaveNoItems() {
assertTrue(order.items().isEmpty());
}
}

@Nested
@DisplayName("When order has items")
class OrderWithItems {

private Order order;

@BeforeEach
void setup() {
order = new Order();
order.addItem(new Item("Book", new BigDecimal("19.99")));
order.addItem(new Item("Pen", new BigDecimal("2.50")));
}

@Test
void shouldCalculateTotal() {
assertEquals(new BigDecimal("22.49"), order.total());
}

@Test
void shouldContainTwoItems() {
assertEquals(2, order.items().size());
}
}
}

Parameterised tests

Run the same test with different inputs:

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

@ParameterizedTest
@ValueSource(strings = {"hello", "world", "java"})
void shouldNotBeEmpty(String input) {
assertFalse(input.isEmpty());
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void shouldRejectBlankInput(String input) {
assertThrows(IllegalArgumentException.class, () -> service.process(input));
}

@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, -5, 5",
"0, 0, 0"
})
void shouldAdd(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}

@ParameterizedTest
@MethodSource("provideUsers")
void shouldValidateUser(User user, boolean expectedValid) {
assertEquals(expectedValid, validator.isValid(user));
}

static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("Alice", "alice@example.com"), true),
Arguments.of(new User("", "alice@example.com"), false),
Arguments.of(new User("Bob", ""), false),
Arguments.of(new User(null, null), false)
);
}

@ParameterizedTest
@EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"})
void shouldBeWeekend(DayOfWeek day) {
assertTrue(calendar.isWeekend(day));
}

AssertJ fluent assertions

AssertJ provides a fluent, IDE-friendly assertion API that is far more readable than JUnit's built-in assertions:

import static org.assertj.core.api.Assertions.*;

@Test
void assertJExamples() {
// Strings
assertThat(name).isEqualTo("Alice");
assertThat(name).startsWith("Al").endsWith("ce").hasSize(5);
assertThat(name).isNotBlank().doesNotContain("Bob");

// Numbers
assertThat(age).isGreaterThan(18).isLessThanOrEqualTo(100);
assertThat(price).isCloseTo(19.99, within(0.01));

// Collections
assertThat(names)
.hasSize(3)
.contains("Alice", "Bob")
.doesNotContain("Eve")
.containsExactly("Alice", "Bob", "Charlie");

assertThat(users)
.extracting(User::name)
.containsExactlyInAnyOrder("Alice", "Bob", "Charlie");

assertThat(users)
.filteredOn(u -> u.age() > 25)
.extracting(User::name)
.containsOnly("Alice", "Charlie");

// Maps
assertThat(map)
.containsKey("name")
.containsEntry("name", "Alice")
.hasSize(3);

// Exceptions
assertThatThrownBy(() -> service.process(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must not be null");

assertThatCode(() -> service.process("valid"))
.doesNotThrowAnyException();

// Optional
assertThat(optional).isPresent().contains("Alice");
assertThat(emptyOptional).isEmpty();
}

Mockito

Basic mocking

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

@Mock
UserRepository userRepository;

@Mock
EmailService emailService;

@InjectMocks
UserService userService; // injects mocks into constructor/fields

@Test
void shouldFindUser() {
// Arrange
User alice = new User("1", "Alice", "alice@example.com");
when(userRepository.findById("1")).thenReturn(Optional.of(alice));

// Act
User result = userService.findUser("1");

// Assert
assertThat(result.name()).isEqualTo("Alice");
verify(userRepository).findById("1");
}
}

Stubbing patterns

// Return value
when(repo.findById("1")).thenReturn(Optional.of(user));

// Return different values on consecutive calls
when(repo.findAll()).thenReturn(List.of(user1)).thenReturn(List.of(user1, user2));

// Throw exception
when(repo.findById("bad")).thenThrow(new RuntimeException("DB down"));

// Answer (dynamic return based on input)
when(repo.save(any())).thenAnswer(invocation -> {
User saved = invocation.getArgument(0);
return new User(UUID.randomUUID().toString(), saved.name(), saved.email());
});

// Void methods
doNothing().when(emailService).send(any());
doThrow(new RuntimeException("SMTP error")).when(emailService).send(any());

Verification

// Was called exactly once (default)
verify(repo).findById("1");

// Call count
verify(repo, times(2)).findById(any());
verify(repo, never()).delete(any());
verify(repo, atLeastOnce()).findAll();
verify(repo, atMost(3)).save(any());

// Argument verification
verify(emailService).send(argThat(email ->
email.to().equals("alice@example.com") && email.subject().contains("Welcome")
));

// No more interactions
verifyNoMoreInteractions(repo);
verifyNoInteractions(emailService);

Argument captors

Capture arguments for detailed assertion:

@Captor
ArgumentCaptor<Email> emailCaptor;

@Test
void shouldSendWelcomeEmail() {
userService.createUser(new CreateUserRequest("Alice", "alice@example.com"));

verify(emailService).send(emailCaptor.capture());
Email sentEmail = emailCaptor.getValue();

assertThat(sentEmail.to()).isEqualTo("alice@example.com");
assertThat(sentEmail.subject()).isEqualTo("Welcome, Alice!");
assertThat(sentEmail.body()).contains("Thank you for signing up");
}

Spies

A spy wraps a real object -- calls go to the real implementation unless stubbed:

@Spy
List<String> spyList = new ArrayList<>();

@Test
void spyExample() {
spyList.add("one");
spyList.add("two");

verify(spyList, times(2)).add(any());
assertThat(spyList).hasSize(2); // real list behaviour

// Override specific method
doReturn(100).when(spyList).size();
assertThat(spyList.size()).isEqualTo(100);
}

Test patterns

Arrange-Act-Assert (AAA)

@Test
void shouldApplyDiscount() {
// Arrange
Order order = new Order(List.of(
new Item("Book", new BigDecimal("20.00")),
new Item("Pen", new BigDecimal("5.00"))
));
DiscountService discountService = new DiscountService();

// Act
BigDecimal total = discountService.applyDiscount(order, 0.10);

// Assert
assertThat(total).isEqualByComparingTo(new BigDecimal("22.50"));
}

Test fixtures with @BeforeEach

class OrderServiceTest {
private OrderService orderService;
private Order sampleOrder;

@BeforeEach
void setup() {
orderService = new OrderService();
sampleOrder = new Order(List.of(
new Item("Book", new BigDecimal("20.00"))
));
}

@Test
void shouldCalculateTotal() { /* ... */ }

@Test
void shouldApplyTax() { /* ... */ }
}

Builder pattern for test data

class TestUsers {
static User.Builder aUser() {
return User.builder()
.id(UUID.randomUUID().toString())
.name("Test User")
.email("test@example.com")
.age(25);
}

static User alice() {
return aUser().name("Alice").email("alice@example.com").build();
}

static User bob() {
return aUser().name("Bob").email("bob@example.com").age(30).build();
}
}

// In tests
User user = TestUsers.aUser().name("Custom").build();

What to mock vs what not to mock

MockDon't mock
External services (HTTP, email, payment)Value objects and DTOs
Database repositoriesPure functions (no side effects)
Third-party APIsSimple data structures
Time/clock (Clock)The class under test itself
File system operationsStatic utility methods (test indirectly)
Message queuesImmutable objects (records)

If you find yourself mocking everything, your design might have too many dependencies. Consider simplifying or restructuring.


Testing exceptions

// JUnit 5
@Test
void shouldThrowOnInvalidInput() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> service.process(null)
);
assertEquals("Input must not be null", ex.getMessage());
}

// AssertJ (more fluent)
@Test
void shouldThrowOnInvalidInput() {
assertThatThrownBy(() -> service.process(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Input must not be null")
.hasNoCause();
}

Temporary files and directories

import org.junit.jupiter.api.io.TempDir;

@Test
void shouldWriteToFile(@TempDir Path tempDir) throws IOException {
Path file = tempDir.resolve("output.txt");
Files.writeString(file, "hello");

assertThat(Files.readString(file)).isEqualTo("hello");
}
// tempDir and its contents are deleted automatically after the test

Common pitfalls

PitfallProblemFix
Testing implementation, not behaviourTests break on every refactorTest public API outcomes, not internal method calls
Too many mocksTests are fragile and hard to readFavour integration tests for complex flows; mock only external boundaries
Not verifying interactionsMock is set up but never checkedUse verify() to confirm expected calls
Shared mutable state between testsTests pass individually, fail togetherUse @BeforeEach for fresh state per test
Ignoring test namesHard to understand failuresUse @DisplayName and descriptive method names
Mocking final classes/methodsMockito throws errorsAdd mockito-inline or use interfaces
@Mock without @ExtendWith(MockitoExtension.class)Mocks are nullAlways add the extension

See also