Testing with JUnit 5, Mockito, and AssertJ
Comprehensive testing patterns for Java and Spring Boot applications.
Use this skill when
- •Writing unit tests for services, controllers, or utility classes
- •Mocking dependencies with Mockito
- •Writing parameterized tests for multiple input scenarios
- •Testing Spring MVC controllers with MockMvc
- •Writing integration tests with real databases using Testcontainers
- •User mentions "test", "JUnit", "Mockito", "AssertJ", "TDD", or "coverage"
- •Verifying exception handling behavior
- •Testing repository queries against a real database
Do not use this skill when
- •User wants end-to-end browser testing (use Selenium/Playwright)
- •User needs performance/load testing (use JMeter/Gatling)
- •User asks about contract testing (use Spring Cloud Contract/Pact)
- •User wants API testing only (use REST Assured standalone)
- •User needs UI component testing for frontend frameworks
Dependencies
Add to pom.xml:
<!-- JUnit 5 BOM - manages all JUnit versions -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
<!-- AssertJ -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.0</version>
<scope>test</scope>
</dependency>
<!-- Testcontainers (for integration tests) -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test (if using Spring) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- Surefire plugin for running tests -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
<!-- Failsafe plugin for integration tests (*IT.java) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Note: If using Spring Boot, spring-boot-starter-test already includes JUnit 5, Mockito, and AssertJ. You only need to add Testcontainers separately.
JUnit 5 Annotations Quick Reference
| Annotation | Purpose |
|---|---|
@Test | Marks a method as a test |
@DisplayName("...") | Human-readable test name |
@BeforeEach | Runs before each test method |
@AfterEach | Runs after each test method |
@BeforeAll | Runs once before all tests (must be static) |
@AfterAll | Runs once after all tests (must be static) |
@Nested | Groups related tests in an inner class |
@Disabled("reason") | Skips a test |
@Tag("integration") | Categorizes tests for filtering |
@ParameterizedTest | Runs test multiple times with different arguments |
@RepeatedTest(n) | Runs test n times |
@Timeout(5) | Fails test if it exceeds time limit |
See references/junit5-annotations.md for the complete annotation reference with examples.
Test Lifecycle
@BeforeAll (once, static) | +-- @BeforeEach | |-- @Test method 1 | +-- @AfterEach | +-- @BeforeEach | |-- @Test method 2 | +-- @AfterEach | +-- ... more tests ... | @AfterAll (once, static)
Test Structure: Arrange-Act-Assert
Every test should follow the AAA pattern:
@Test
@DisplayName("Should calculate total price with discount")
void shouldCalculateTotalPriceWithDiscount() {
// Arrange - set up test data and dependencies
var product = new Product("Laptop", new BigDecimal("1000.00"));
var discount = new Discount(10); // 10%
// Act - execute the behavior under test
BigDecimal result = pricingService.calculateTotal(product, discount);
// Assert - verify the outcome
assertThat(result).isEqualByComparingTo(new BigDecimal("900.00"));
}
Assertions
JUnit 5 Built-in Assertions
import static org.junit.jupiter.api.Assertions.*;
assertEquals(expected, actual);
assertNotEquals(unexpected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertSame(expected, actual); // reference equality
assertArrayEquals(expected, actual);
assertIterableEquals(expected, actual);
assertThrows(Exception.class, () -> { /* code */ });
assertDoesNotThrow(() -> { /* code */ });
assertTimeout(Duration.ofSeconds(2), () -> { /* code */ });
assertAll("group",
() -> assertEquals("John", user.getFirstName()),
() -> assertEquals("Doe", user.getLastName())
);
AssertJ Fluent Assertions (Preferred)
import static org.assertj.core.api.Assertions.*;
// Strings
assertThat(name).isNotBlank()
.startsWith("John")
.hasSize(8)
.containsIgnoringCase("john");
// Numbers
assertThat(age).isPositive()
.isGreaterThan(17)
.isBetween(18, 65);
// Collections
assertThat(users).hasSize(3)
.extracting(User::getName)
.containsExactly("Alice", "Bob", "Charlie");
// Objects
assertThat(actual).isEqualTo(expected);
assertThat(actual).usingRecursiveComparison()
.ignoringFields("id", "createdAt")
.isEqualTo(expected);
// Exceptions
assertThatThrownBy(() -> service.findById(999L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("not found");
// Soft assertions (collect all failures)
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(user.getName()).isEqualTo("John");
softly.assertThat(user.getEmail()).contains("@");
softly.assertThat(user.getAge()).isGreaterThan(0);
});
See examples/AssertJExamples.java for comprehensive AssertJ examples.
Mockito
Basic Setup
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
}
Stubbing
// Return a value
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// Return different values on consecutive calls
when(service.generate()).thenReturn("first", "second", "third");
// Throw an exception
when(userRepository.findById(999L)).thenThrow(new ResourceNotFoundException("Not found"));
// Void method throw
doThrow(new RuntimeException("fail")).when(emailService).send(any());
// Answer dynamically
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User u = invocation.getArgument(0);
u.setId(1L);
return u;
});
Verification
// Verify method was called
verify(userRepository).save(any(User.class));
// Verify call count
verify(emailService, times(1)).send(any());
verify(emailService, never()).send(any());
verify(emailService, atLeastOnce()).send(any());
// Verify argument value
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
User savedUser = captor.getValue();
assertThat(savedUser.getName()).isEqualTo("John");
// Verify call order
InOrder inOrder = inOrder(userRepository, emailService);
inOrder.verify(userRepository).save(any());
inOrder.verify(emailService).send(any());
See references/mockito-patterns.md for the complete Mockito reference.
See examples/UserServiceTest.java for a full service test example.
See examples/MockitoAdvancedExamples.java for advanced patterns.
Parameterized Tests
@ParameterizedTest
@ValueSource(strings = {"", " ", " "})
void shouldRejectBlankNames(String name) {
assertThatThrownBy(() -> new User(name))
.isInstanceOf(IllegalArgumentException.class);
}
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"100, -1, 99"
})
void shouldAddNumbers(int a, int b, int expected) {
assertThat(calculator.add(a, b)).isEqualTo(expected);
}
@ParameterizedTest
@MethodSource("validEmailProvider")
void shouldAcceptValidEmails(String email) {
assertThat(validator.isValidEmail(email)).isTrue();
}
static Stream<String> validEmailProvider() {
return Stream.of("user@example.com", "admin@test.org", "name+tag@domain.co");
}
@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"PENDING", "PROCESSING"})
void shouldAllowCancellation(OrderStatus status) {
var order = new Order(status);
assertThat(order.canCancel()).isTrue();
}
See examples/ParameterizedExamples.java for all parameterized test types.
Exception Testing
// Assert exception is thrown
@Test
void shouldThrowWhenUserNotFound() {
when(userRepository.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findById(999L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("User not found with id: 999")
.hasNoCause();
}
// JUnit 5 style
@Test
void shouldThrowWithJUnit() {
var exception = assertThrows(IllegalArgumentException.class,
() -> validator.validate(null));
assertEquals("Input must not be null", exception.getMessage());
}
// Assert no exception
@Test
void shouldNotThrowForValidInput() {
assertDoesNotThrow(() -> validator.validate("valid input"));
}
Testcontainers for PostgreSQL
@SpringBootTest
@Testcontainers
class UserRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
var user = new User("John", "john@example.com");
var saved = userRepository.save(user);
var found = userRepository.findById(saved.getId());
assertThat(found).isPresent()
.get()
.extracting(User::getName, User::getEmail)
.containsExactly("John", "john@example.com");
}
}
See examples/UserRepositoryIT.java for a complete integration test example.
Spring MockMvc Testing
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUser() throws Exception {
when(userService.findById(1L)).thenReturn(new UserDTO(1L, "John", "john@example.com"));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
}
See examples/UserControllerTest.java for a complete controller test example.
Test Naming Conventions
Follow consistent naming for test classes and methods:
| Type | Convention | Example |
|---|---|---|
| Unit test class | {ClassName}Test | UserServiceTest |
| Integration test class | {ClassName}IT | UserRepositoryIT |
| Test method | should{ExpectedBehavior} or should{Action}When{Condition} | shouldReturnUserWhenIdExists |
Test Organization with @Nested
@DisplayName("UserService")
class UserServiceTest {
@Nested
@DisplayName("findById")
class FindById {
@Test
@DisplayName("should return user when exists")
void shouldReturnUserWhenExists() { /* ... */ }
@Test
@DisplayName("should throw when not found")
void shouldThrowWhenNotFound() { /* ... */ }
}
@Nested
@DisplayName("createUser")
class CreateUser {
@Test
@DisplayName("should save and return new user")
void shouldSaveAndReturnNewUser() { /* ... */ }
@Test
@DisplayName("should throw when email already exists")
void shouldThrowWhenEmailExists() { /* ... */ }
}
}
Code Quality Checklist
- • Every public method in service/controller has at least one test
- • Tests follow Arrange-Act-Assert pattern
- • Tests use
@DisplayNamefor readable test output - • Related tests are grouped with
@Nested - • No test depends on another test's execution order
- • Mocks are verified for important interactions (
verify()) - • Edge cases are covered (null, empty, boundary values)
- • Exception scenarios are tested (
assertThrows,assertThatThrownBy) - • Integration tests use Testcontainers (not H2) for database testing
- • Parameterized tests are used when testing multiple inputs
- • No production code in test scope; no test code in main scope
- • Test data is created in
@BeforeEachor factory methods, not shared mutably - • Tests run independently and can execute in any order
- • Assertions are specific (not just
assertNotNull) - • AssertJ is preferred over JUnit assertions for readability
File Reference
- •
references/junit5-annotations.md- Complete JUnit 5 annotation reference - •
references/mockito-patterns.md- Complete Mockito patterns and best practices - •
examples/UserServiceTest.java- Unit test for a service with mocked repository - •
examples/UserControllerTest.java- MockMvc test for a Spring controller - •
examples/ParameterizedExamples.java- All parameterized test types - •
examples/AssertJExamples.java- AssertJ fluent assertion examples - •
examples/UserRepositoryIT.java- Integration test with Testcontainers PostgreSQL - •
examples/MockitoAdvancedExamples.java- Advanced Mockito patterns