Qaskills JUnit 5 Testing
Production-grade Java unit and integration testing with JUnit 5 covering assertions, parameterized tests, lifecycle hooks, Mockito mocking, nested tests, and extensions.
install
source · Clone the upstream repo
git clone https://github.com/PramodDutta/qaskills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/PramodDutta/qaskills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/seed-skills/junit5-testing" ~/.claude/skills/pramoddutta-qaskills-junit-5-testing && rm -rf "$T"
manifest:
seed-skills/junit5-testing/SKILL.mdsource content
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
method should verify a single behavior so failures pinpoint the exact issue immediately.@Test - 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
to create human-readable test descriptions that serve as living documentation.@DisplayName - Leverage parameterized tests -- Use
with sources like@ParameterizedTest
,@ValueSource
, and@CsvSource
to test multiple inputs without code duplication.@MethodSource - Use nested tests for organization -- Group related tests with
inner classes to mirror conditions and behavior hierarchies.@Nested
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
for readable output -- Annotate every test with a human-readable description that explains the behavior being verified.@DisplayName - Use
for related assertions -- Group related assertions so all are evaluated even if one fails, providing a complete picture of what went wrong.assertAll - Prefer
over copy-paste -- When testing multiple inputs, use parameterized tests with@ParameterizedTest
or@CsvSource
to reduce duplication.@MethodSource - Use
to organize by state -- Group tests by preconditions using inner classes to create a readable hierarchy of test scenarios.@Nested - Follow naming convention -- Use
for method names andmethodName_scenario_expectedResult
for readable output.@DisplayName - Use
for complex verifications -- Capture arguments passed to mocks and assert on them separately for cleaner verification code.ArgumentCaptor - Prefer constructor injection -- Design classes with constructor injection for easier testing; use
with Mockito for automatic wiring.@InjectMocks - Test edge cases and boundaries -- Include null inputs, empty collections, maximum values, and negative numbers in parameterized test data.
- Use
overassertThrows
-- The JUnit 5@Test(expected=...)
method is more precise and allows verifying the exception message.assertThrows - 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
with instance state --@BeforeAll
must be static in standard mode; mixing static and instance state causes confusion and errors.@BeforeAll - Ignoring
cleanup -- Not cleaning up resources like files, connections, or mock state leads to flaky tests and resource leaks.@AfterEach - Over-mocking -- Mocking every dependency including simple value objects reduces test confidence; mock only external I/O.
- Multiple unrelated assertions without
-- If the first assertion fails, subsequent ones are not checked; useassertAll
for complete validation.assertAll - Hardcoded test data everywhere -- Scatter magic numbers and strings across tests; extract shared test data into a
helper.TestDataFactory - 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
to verify exceptions cleanly.assertThrows - Not using
-- Manually initializing mocks with@ExtendWith(MockitoExtension.class)
is error-prone; use the extension.MockitoAnnotations.openMocks() - Ignoring test output -- Not reading test names and failure messages means missing valuable diagnostic information; write tests as documentation.