junit-expert
Use when writing, reviewing, or structuring JUnit 5 tests for Java applications. Invoke for test lifecycle, parameterized tests, test extensions, assertions, test organization, parallel execution, test suites, dynamic tests, and integration with build tools.
JUnit Expert
JUnit 5 (Jupiter) specialist for comprehensive, maintainable, and expressive Java test suites.
Role Definition
Designs and implements JUnit 5 test suites that are fast, isolated, readable, and aligned with the test pyramid. Applies the full JUnit 5 API surface pragmatically โ from basic lifecycle management to advanced extension authoring and dynamic test generation.
When to Use This Skill
- Writing new unit or integration tests for Java classes
- Restructuring disorganized or fragile test classes
- Implementing parameterized or data-driven test scenarios
- Authoring custom JUnit 5 extensions for cross-cutting concerns
- Configuring parallel test execution and test suite composition
- Diagnosing flaky or slow test behavior
Core Workflow
- Identify the unit under test and its collaborators
- Define test scope: unit, slice, or integration
- Structure the test class with appropriate lifecycle hooks
- Write focused, single-assertion-per-test cases
- Cover edge cases with parameterized inputs
- Validate build integration and execution time
JUnit 5 Architecture
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
| Component | Role |
|---|---|
| Platform | Test engine launcher, IDE/build integration |
| Jupiter | New programming and extension model (use this) |
| Vintage | Backward compatibility runner for JUnit 3/4 |
Dependency Setup
Maven
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<!-- Maven Surefire plugin must be 2.22.0+ -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
Gradle
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
Test Lifecycle Annotations
| Annotation | Scope | Runs |
|---|---|---|
@Test | method | marks a test method |
@BeforeAll | static method | once before all tests in the class |
@AfterAll | static method | once after all tests in the class |
@BeforeEach | method | before each test method |
@AfterEach | method | after each test method |
@Disabled | class or method | skips the test |
@Tag | class or method | categorizes for filtering |
@Timeout | method | fails if execution exceeds duration |
Lifecycle Example
@Tag("unit")
class OrderServiceTest {
private OrderService service;
@BeforeEach
void setUp() {
service = new OrderService(new InMemoryOrderRepository());
}
@AfterEach
void tearDown() {
// release resources if needed
}
@Test
void shouldAcceptValidOrder() {
var order = Order.of("item-1", 3);
var result = service.place(order);
assertThat(result.status()).isEqualTo(OrderStatus.ACCEPTED);
}
@Test
@Disabled("pending inventory service integration")
void shouldRejectOutOfStockOrder() { }
}
Assertions (JUnit Jupiter + AssertJ)
Prefer AssertJ for rich, fluent assertions. Use JUnit built-ins for basic cases.
JUnit Built-in Assertions
import static org.junit.jupiter.api.Assertions.*;
// equality
assertEquals(expected, actual);
assertEquals(expected, actual, "message on failure");
// nullability
assertNotNull(result);
assertNull(result);
// boolean
assertTrue(condition);
assertFalse(condition);
// exceptions
assertThrows(IllegalArgumentException.class, () -> service.place(null));
var ex = assertThrows(OrderException.class, () -> service.cancel(closedOrder));
assertEquals("Order already closed", ex.getMessage());
// grouped assertions โ all run even if one fails
assertAll(
() -> assertEquals("Alice", user.name()),
() -> assertEquals("[email protected]", user.email()),
() -> assertTrue(user.isActive())
);
// timeout
assertTimeout(Duration.ofMillis(200), () -> service.process(largeOrder));
AssertJ (Preferred)
import static org.assertj.core.api.Assertions.*;
// object
assertThat(result).isNotNull().isInstanceOf(Order.class);
// strings
assertThat(result.id()).startsWith("ORD-").hasSize(10);
// collections
assertThat(orders).hasSize(3).extracting(Order::status)
.containsExactly(ACCEPTED, ACCEPTED, REJECTED);
// exceptions
assertThatThrownBy(() -> service.place(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("order must not be null");
// optionals
assertThat(service.findById("missing")).isEmpty();
Nested Tests (@Nested)
Use @Nested to express hierarchical test structure for complex units.
class PaymentProcessorTest {
@Nested
@DisplayName("when payment method is credit card")
class CreditCardPayment {
@Test
void shouldAuthorizeValidCard() { ... }
@Test
void shouldDeclineExpiredCard() { ... }
@Nested
@DisplayName("and 3DS is required")
class With3DS {
@Test
void shouldTrigger3DSChallenge() { ... }
}
}
@Nested
@DisplayName("when payment method is bank transfer")
class BankTransfer {
@Test
void shouldQueueTransfer() { ... }
}
}
Parameterized Tests
@ValueSource โ single primitive/string argument
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void shouldRejectBlankNames(String name) {
assertThatThrownBy(() -> new User(name, "[email protected]"))
.isInstanceOf(IllegalArgumentException.class);
}
@ParameterizedTest
@ValueSource(ints = {-1, 0, Integer.MIN_VALUE})
void shouldRejectNonPositiveQuantity(int qty) {
assertThatThrownBy(() -> Order.of("item", qty))
.isInstanceOf(IllegalArgumentException.class);
}
@CsvSource โ multiple inline arguments
@ParameterizedTest
@CsvSource({
"STANDARD, 3, 9.99",
"EXPRESS, 1, 19.99",
"OVERNIGHT, 0, 34.99"
})
void shouldCalculateShippingCost(String tier, int days, double expectedCost) {
var cost = shippingCalculator.calculate(ShippingTier.valueOf(tier), days);
assertThat(cost).isEqualByComparingTo(BigDecimal.valueOf(expectedCost));
}
@CsvFileSource โ CSV from classpath file
@ParameterizedTest
@CsvFileSource(resources = "/test-data/orders.csv", numLinesToSkip = 1)
void shouldProcessOrderFromFile(String orderId, int quantity, String expected) { ... }
@MethodSource โ complex object arguments
@ParameterizedTest
@MethodSource("invalidOrders")
void shouldRejectInvalidOrders(Order order, String expectedMessage) {
assertThatThrownBy(() -> service.place(order))
.hasMessageContaining(expectedMessage);
}
static Stream<Arguments> invalidOrders() {
return Stream.of(
Arguments.of(Order.of(null, 1), "item must not be null"),
Arguments.of(Order.of("", 1), "item must not be blank"),
Arguments.of(Order.of("item", 0), "quantity must be positive")
);
}
@EnumSource โ enum values
@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"CANCELLED", "REJECTED"})
void shouldNotAllowModificationOfTerminalOrders(OrderStatus status) {
var order = Order.withStatus(status);
assertThatThrownBy(() -> service.modify(order))
.isInstanceOf(IllegalStateException.class);
}
Dynamic Tests (@TestFactory)
Generate tests at runtime when the set of cases is not known statically.
@TestFactory
Stream<DynamicTest> shouldValidateAllCountryCodes() {
return CountryRegistry.allCodes().stream()
.map(code -> dynamicTest(
"should accept country code: " + code,
() -> assertThat(validator.isValid(code)).isTrue()
));
}
@TestFactory
Collection<DynamicContainer> shouldValidateProductRules() {
return List.of(
dynamicContainer("physical products", Stream.of(
dynamicTest("requires weight", () -> ...),
dynamicTest("requires dimensions", () -> ...)
)),
dynamicContainer("digital products", Stream.of(
dynamicTest("requires download URL", () -> ...)
))
);
}
JUnit 5 Extensions
Extensions replace JUnit 4 @Rule / @ClassRule. Implement one or more extension interfaces.
Common Extension Interfaces
| Interface | Purpose |
|---|---|
BeforeEachCallback | before each test |
AfterEachCallback | after each test |
BeforeAllCallback | before all tests in class |
AfterAllCallback | after all tests in class |
ParameterResolver | inject parameters into test methods |
TestExecutionExceptionHandler | handle/transform exceptions |
TestInstancePostProcessor | post-process test instance after creation |
ConditionEvaluationResult | control whether test runs |
Custom Extension Example โ Database Cleanup
public class DatabaseCleanupExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
getDatabase(context).beginTransaction();
}
@Override
public void afterEach(ExtensionContext context) {
getDatabase(context).rollback();
}
private Database getDatabase(ExtensionContext ctx) {
return ctx.getStore(Namespace.create(getClass(), ctx.getRequiredTestClass()))
.getOrComputeIfAbsent("db", k -> Database.connect(), Database.class);
}
}
@ExtendWith(DatabaseCleanupExtension.class)
class OrderRepositoryTest { ... }
@RegisterExtension (programmatic)
class UserServiceTest {
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Test
void shouldCallExternalApi() {
wireMock.stubFor(get("/users/1").willReturn(okJson("{\"name\":\"Alice\"}")));
var user = service.fetchUser(1L);
assertThat(user.name()).isEqualTo("Alice");
}
}
Conditional Test Execution
@Test
@EnabledOnOs(OS.LINUX)
void shouldUseLinuxFilePermissions() { ... }
@Test
@EnabledOnJre(JRE.JAVA_21)
void shouldUseVirtualThreads() { ... }
@Test
@EnabledIfSystemProperty(named = "env", matches = "ci")
void shouldRunOnlyInCI() { ... }
@Test
@EnabledIfEnvironmentVariable(named = "RUN_SLOW_TESTS", matches = "true")
void shouldRunSlowIntegrationTest() { ... }
Parallel Test Execution
Enable in src/test/resources/junit-platform.properties:
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=2
Control per-class or per-method:
@Execution(ExecutionMode.CONCURRENT)
class FastIsolatedTest { ... }
@Execution(ExecutionMode.SAME_THREAD)
class SharedStateTest { ... }
Warning: Do not enable concurrent mode for tests sharing mutable static state or external resources without synchronization.
Test Suites
@Suite
@SelectPackages("com.example.orders")
@IncludeTags("unit")
@ExcludeTags("slow")
class OrderUnitTestSuite { }
@Suite
@SelectClasses({OrderServiceTest.class, PaymentProcessorTest.class})
class SmokeTestSuite { }
Requires:
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<version>1.11.0</version>
<scope>test</scope>
</dependency>
Temporary Files and Directories
@Test
void shouldWriteReportToFile(@TempDir Path tempDir) {
var reportFile = tempDir.resolve("report.csv");
reporter.write(data, reportFile);
assertThat(reportFile).exists().isNotEmpty();
}
Capturing Output
@Test
void shouldLogWarningForLargeOrders(CapturedOutput output) {
service.place(Order.of("item", 10_000));
assertThat(output).contains("WARN: unusually large quantity");
}
// Requires Spring Boot test slice or OutputCaptureExtension
Ordering Tests
Avoid ordering unless testing stateful workflows. Use @TestMethodOrder only when necessary:
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CheckoutFlowTest {
@Test @Order(1) void shouldAddItemToCart() { ... }
@Test @Order(2) void shouldApplyCoupon() { ... }
@Test @Order(3) void shouldCompletePurchase() { ... }
}
Reference Guide
| Topic | Recommendation | Validation |
|---|---|---|
| Test structure | Arrange-Act-Assert (AAA) | readable, one concept per test |
| Naming | should<Behavior>When<Condition>() | enforced via convention |
| Isolation | no shared mutable state between tests | parallel run green |
| Coverage | cover happy path, edge cases, and error paths | mutation testing |
| Speed | unit tests < 50ms each | CI timing gates |
| Assertions | prefer AssertJ for readability | code review |
Constraints
MUST DO
- Follow Arrange-Act-Assert layout in every test method
- Test one logical behavior per
@Testmethod - Keep tests independent โ no execution order dependencies
- Use
@DisplayNamefor non-obvious test names - Assert on behavior, not on implementation details
MUST NOT DO
- Do not use
Thread.sleep()โ use@Timeoutor polling utilities - Do not share mutable fields between test methods without
@BeforeEachreset - Do not skip
@AfterEachcleanup for resources that can leak - Do not test private methods directly โ test through the public API
- Do not mix unit tests and integration tests in the same class
Output Templates
## Test Coverage Review
- Class under test: [ClassName]
- Test class: [ClassNameTest]
- Covered behaviors: [list]
- Missing cases: [list]
- Recommended parameterized expansions: [list]
- Lifecycle hooks needed: [BeforeEach/AfterAll/etc.]
// Standard test class template
@Tag("unit")
@DisplayName("OrderService")
class OrderServiceTest {
private OrderService sut;
@BeforeEach
void setUp() {
sut = new OrderService(/* dependencies */);
}
@Nested
@DisplayName("place()")
class Place {
@Test
@DisplayName("should accept a valid order")
void shouldAcceptValidOrder() {
// Arrange
var order = Order.of("item-1", 2);
// Act
var result = sut.place(order);
// Assert
assertThat(result.status()).isEqualTo(OrderStatus.ACCEPTED);
}
@ParameterizedTest(name = "should reject when quantity is {0}")
@ValueSource(ints = {-5, -1, 0})
void shouldRejectNonPositiveQuantity(int qty) {
assertThatThrownBy(() -> sut.place(Order.of("item", qty)))
.isInstanceOf(IllegalArgumentException.class);
}
}
}
Knowledge Reference
junit5, junit jupiter, @test, @parameterizedtest, @nestedtest, @dynamictest, @testfactory, @extendwith, @registerextension, assertj, assertions, parameterized tests, test lifecycle, @beforeeach, @aftereach, @beforeall, @afterall, @tag, @disabled, @timeout, @tempdir, parallel execution, test suites, junit platform, extension model, condition annotations, @enabledonos, @enabledonjre, test ordering, @displayname, @methodsource, @csvsource, @valuesource, @enumsource, maven surefire, gradle junit platform
Related Skills
mockito-experttestcontainers-expertjava-expertcode-reviewer