name: JUnit 5 Testing description: Production-grade Java unit and integration testing with JUnit 5 covering assertions, parameterized tests, lifecycle hooks, Mockito mocking, nested tests, and extensions. version: 1.0.0 author: thetestingacademy license: MIT tags: [junit5, java, unit-testing, mockito, parameterized, assertions, tdd, integration] testingTypes: [unit, integration] frameworks: [junit5] languages: [java] domains: [web, api, backend] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt]
JUnit 5 Testing Skill
You are an expert Java developer specializing in testing with JUnit 5 (Jupiter). When the user asks you to write, review, or debug JUnit 5 tests, follow these detailed instructions to produce production-grade test suites with clear structure, comprehensive assertions, and effective use of the JUnit 5 API.
Core Principles
- Test behavior, not implementation -- Verify what the code does from a caller's perspective, not internal mechanics that may change during refactoring.
- One logical assertion per test -- Each
@Testmethod should verify a single behavior so failures pinpoint the exact issue immediately. - Arrange-Act-Assert -- Structure every test into setup, execution, and verification sections separated by blank lines.
- Isolate external dependencies -- Use Mockito to mock databases, HTTP clients, and third-party services in unit tests.
- Descriptive display names -- Use
@DisplayNameto create human-readable test descriptions that serve as living documentation. - Leverage parameterized tests -- Use
@ParameterizedTestwith sources like@ValueSource,@CsvSource, and@MethodSourceto test multiple inputs without code duplication. - Use nested tests for organization -- Group related tests with
@Nestedinner classes to mirror conditions and behavior hierarchies.
Project Structure
src/
main/java/com/example/
service/
UserService.java
PaymentService.java
model/
User.java
Order.java
repository/
UserRepository.java
util/
Validators.java
test/java/com/example/
service/
UserServiceTest.java
PaymentServiceTest.java
model/
UserTest.java
OrderTest.java
repository/
UserRepositoryTest.java
util/
ValidatorsTest.java
integration/
UserPaymentFlowIT.java
fixtures/
TestDataFactory.java
pom.xml (or build.gradle)
Dependencies
Maven (pom.xml)
<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>
</dependencies>
Gradle (build.gradle)
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.14.0'
testImplementation 'org.assertj:assertj-core:3.26.0'
}
test {
useJUnitPlatform()
}
Basic Test Structure
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("UserService")
class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository = new InMemoryUserRepository();
userService = new UserService(userRepository);
}
@AfterEach
void tearDown() {
userRepository = null;
userService = null;
}
@Test
@DisplayName("should create user with valid data")
void createUser_withValidData_returnsUser() {
var request = new CreateUserRequest("Alice", "alice@example.com", 30);
var user = userService.createUser(request);
assertNotNull(user);
assertEquals("Alice", user.getName());
assertEquals("alice@example.com", user.getEmail());
}
@Test
@DisplayName("should throw exception when email is missing")
void createUser_withoutEmail_throwsException() {
var request = new CreateUserRequest("Bob", null, 25);
var exception = assertThrows(IllegalArgumentException.class,
() -> userService.createUser(request));
assertTrue(exception.getMessage().contains("email"));
}
@Test
@DisplayName("should throw exception for duplicate email")
void createUser_withDuplicateEmail_throwsException() {
var request = new CreateUserRequest("Alice", "alice@example.com", 30);
userService.createUser(request);
assertThrows(DuplicateEmailException.class,
() -> userService.createUser(request));
}
}
Assertion Patterns
@DisplayName("Assertion examples")
class AssertionExamplesTest {
@Test
@DisplayName("equality assertions")
void testEquality() {
assertEquals(4, 2 + 2);
assertNotEquals(5, 2 + 2);
assertEquals(0.3, 0.1 + 0.2, 0.001); // delta for floating point
}
@Test
@DisplayName("boolean assertions")
void testBooleans() {
assertTrue(10 > 5);
assertFalse(5 > 10);
assertNull(null);
assertNotNull("value");
}
@Test
@DisplayName("grouped assertions with assertAll")
void testGrouped() {
var user = new User("Alice", "alice@example.com", 30);
assertAll("user properties",
() -> assertEquals("Alice", user.getName()),
() -> assertEquals("alice@example.com", user.getEmail()),
() -> assertEquals(30, user.getAge())
);
}
@Test
@DisplayName("exception assertions")
void testExceptions() {
var exception = assertThrows(ArithmeticException.class,
() -> { int result = 1 / 0; });
assertEquals("/ by zero", exception.getMessage());
}
@Test
@DisplayName("timeout assertions")
void testTimeout() {
assertTimeout(Duration.ofSeconds(2), () -> {
Thread.sleep(100);
});
}
@Test
@DisplayName("iterable assertions")
void testIterables() {
var list = List.of(1, 2, 3);
assertIterableEquals(List.of(1, 2, 3), list);
}
}
Parameterized Tests
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ValidatorTest {
@ParameterizedTest
@ValueSource(strings = {"user@example.com", "admin@test.org", "a@b.co"})
@DisplayName("should accept valid emails")
void isValidEmail_withValidEmails_returnsTrue(String email) {
assertTrue(Validators.isValidEmail(email));
}
@ParameterizedTest
@ValueSource(strings = {"", "not-email", "@domain.com", "user@"})
@DisplayName("should reject invalid emails")
void isValidEmail_withInvalidEmails_returnsFalse(String email) {
assertFalse(Validators.isValidEmail(email));
}
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300",
"-50, -50, -100"
})
@DisplayName("should add two numbers correctly")
void add_withVariousInputs_returnsSum(int a, int b, int expected) {
assertEquals(expected, Calculator.add(a, b));
}
@ParameterizedTest
@MethodSource("provideAgeValidationData")
@DisplayName("should validate age boundaries")
void isValidAge_withBoundaryValues(int age, boolean expected) {
assertEquals(expected, Validators.isValidAge(age));
}
static Stream<Arguments> provideAgeValidationData() {
return Stream.of(
Arguments.of(0, false),
Arguments.of(1, true),
Arguments.of(17, false),
Arguments.of(18, true),
Arguments.of(120, true),
Arguments.of(121, false),
Arguments.of(-1, false)
);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
@DisplayName("should reject blank strings")
void isBlank_withBlankStrings_returnsTrue(String input) {
assertTrue(Validators.isBlank(input));
}
}
Mockito Integration
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("UserService with mocks")
class UserServiceMockTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Captor
private ArgumentCaptor<User> userCaptor;
@Test
@DisplayName("should save user to repository")
void createUser_savesToRepository() {
var request = new CreateUserRequest("Alice", "alice@example.com", 30);
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> {
var user = invocation.getArgument(0, User.class);
user.setId(1L);
return user;
});
userService.createUser(request);
verify(userRepository).save(userCaptor.capture());
var savedUser = userCaptor.getValue();
assertEquals("Alice", savedUser.getName());
assertEquals("alice@example.com", savedUser.getEmail());
}
@Test
@DisplayName("should send welcome email after creation")
void createUser_sendsWelcomeEmail() {
when(userRepository.save(any())).thenAnswer(inv -> {
var user = inv.getArgument(0, User.class);
user.setId(1L);
return user;
});
userService.createUser(new CreateUserRequest("Bob", "bob@example.com", 25));
verify(emailService).sendWelcomeEmail("bob@example.com");
verifyNoMoreInteractions(emailService);
}
@Test
@DisplayName("should handle email failure gracefully")
void createUser_emailFails_doesNotThrow() {
when(userRepository.save(any())).thenAnswer(inv -> {
var user = inv.getArgument(0, User.class);
user.setId(1L);
return user;
});
doThrow(new RuntimeException("SMTP error"))
.when(emailService).sendWelcomeEmail(anyString());
assertDoesNotThrow(() ->
userService.createUser(new CreateUserRequest("Bob", "bob@example.com", 25))
);
}
}
Nested Tests
@DisplayName("ShoppingCart")
class ShoppingCartTest {
private ShoppingCart cart;
@BeforeEach
void setUp() {
cart = new ShoppingCart();
}
@Nested
@DisplayName("when empty")
class WhenEmpty {
@Test
@DisplayName("should have zero items")
void hasZeroItems() {
assertEquals(0, cart.getItemCount());
}
@Test
@DisplayName("should have zero total")
void hasZeroTotal() {
assertEquals(BigDecimal.ZERO, cart.getTotal());
}
@Test
@DisplayName("should throw when removing item")
void throwsOnRemove() {
assertThrows(NoSuchElementException.class,
() -> cart.removeItem("Widget"));
}
}
@Nested
@DisplayName("when items added")
class WhenItemsAdded {
@BeforeEach
void addItems() {
cart.addItem(new CartItem("Widget", new BigDecimal("9.99"), 2));
}
@Test
@DisplayName("should update item count")
void updatesItemCount() {
assertEquals(2, cart.getItemCount());
}
@Test
@DisplayName("should calculate total correctly")
void calculatesTotal() {
assertEquals(new BigDecimal("19.98"), cart.getTotal());
}
@Nested
@DisplayName("and discount applied")
class AndDiscountApplied {
@Test
@DisplayName("should reduce total by discount percentage")
void reducesTotal() {
cart.applyDiscount(0.1);
assertEquals(new BigDecimal("17.98"), cart.getTotal());
}
}
}
}
Lifecycle Hooks
class LifecycleExampleTest {
@BeforeAll
static void setUpOnce() {
// Runs once before all tests (must be static)
System.out.println("Setting up shared resources");
}
@AfterAll
static void tearDownOnce() {
// Runs once after all tests (must be static)
System.out.println("Cleaning up shared resources");
}
@BeforeEach
void setUp() {
// Runs before each test
}
@AfterEach
void tearDown() {
// Runs after each test
}
@Test
void testExample() {
// Test logic here
}
}
Best Practices
- Use
@DisplayNamefor readable output -- Annotate every test with a human-readable description that explains the behavior being verified. - Use
assertAllfor related assertions -- Group related assertions so all are evaluated even if one fails, providing a complete picture of what went wrong. - Prefer
@ParameterizedTestover copy-paste -- When testing multiple inputs, use parameterized tests with@CsvSourceor@MethodSourceto reduce duplication. - Use
@Nestedto organize by state -- Group tests by preconditions using inner classes to create a readable hierarchy of test scenarios. - Follow naming convention -- Use
methodName_scenario_expectedResultfor method names and@DisplayNamefor readable output. - Use
ArgumentCaptorfor complex verifications -- Capture arguments passed to mocks and assert on them separately for cleaner verification code. - Prefer constructor injection -- Design classes with constructor injection for easier testing; use
@InjectMockswith Mockito for automatic wiring. - Test edge cases and boundaries -- Include null inputs, empty collections, maximum values, and negative numbers in parameterized test data.
- Use
assertThrowsover@Test(expected=...)-- The JUnit 5assertThrowsmethod is more precise and allows verifying the exception message. - Keep tests fast and independent -- Unit tests should complete in milliseconds with no shared mutable state between test methods.
Anti-Patterns
- Testing private methods -- Accessing private methods via reflection couples tests to implementation details; test through public API instead.
- Using
@BeforeAllwith instance state --@BeforeAllmust be static in standard mode; mixing static and instance state causes confusion and errors. - Ignoring
@AfterEachcleanup -- Not cleaning up resources like files, connections, or mock state leads to flaky tests and resource leaks. - Over-mocking -- Mocking every dependency including simple value objects reduces test confidence; mock only external I/O.
- Multiple unrelated assertions without
assertAll-- If the first assertion fails, subsequent ones are not checked; useassertAllfor complete validation. - Hardcoded test data everywhere -- Scatter magic numbers and strings across tests; extract shared test data into a
TestDataFactoryhelper. - Tests depending on execution order -- Never rely on another test's side effects; each test must be independently runnable.
- Catching exceptions manually -- Using try-catch in tests swallows failures; use
assertThrowsto verify exceptions cleanly. - Not using
@ExtendWith(MockitoExtension.class)-- Manually initializing mocks withMockitoAnnotations.openMocks()is error-prone; use the extension. - Ignoring test output -- Not reading test names and failure messages means missing valuable diagnostic information; write tests as documentation.