๐Ÿ”ท Core

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.

$ npx skills add ortus-solutions/skills/junit-expert
$ coldbox ai skills install ortus-solutions/skills/junit-expert
๐Ÿ”— https://skills.boxlang.io/skills/raw/ortus-solutions/skills/junit-expert

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

  1. Identify the unit under test and its collaborators
  2. Define test scope: unit, slice, or integration
  3. Structure the test class with appropriate lifecycle hooks
  4. Write focused, single-assertion-per-test cases
  5. Cover edge cases with parameterized inputs
  6. Validate build integration and execution time

JUnit 5 Architecture

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

ComponentRole
PlatformTest engine launcher, IDE/build integration
JupiterNew programming and extension model (use this)
VintageBackward 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

AnnotationScopeRuns
@Testmethodmarks a test method
@BeforeAllstatic methodonce before all tests in the class
@AfterAllstatic methodonce after all tests in the class
@BeforeEachmethodbefore each test method
@AfterEachmethodafter each test method
@Disabledclass or methodskips the test
@Tagclass or methodcategorizes for filtering
@Timeoutmethodfails 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

InterfacePurpose
BeforeEachCallbackbefore each test
AfterEachCallbackafter each test
BeforeAllCallbackbefore all tests in class
AfterAllCallbackafter all tests in class
ParameterResolverinject parameters into test methods
TestExecutionExceptionHandlerhandle/transform exceptions
TestInstancePostProcessorpost-process test instance after creation
ConditionEvaluationResultcontrol 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

TopicRecommendationValidation
Test structureArrange-Act-Assert (AAA)readable, one concept per test
Namingshould<Behavior>When<Condition>()enforced via convention
Isolationno shared mutable state between testsparallel run green
Coveragecover happy path, edge cases, and error pathsmutation testing
Speedunit tests < 50ms eachCI timing gates
Assertionsprefer AssertJ for readabilitycode review

Constraints

MUST DO

  • Follow Arrange-Act-Assert layout in every test method
  • Test one logical behavior per @Test method
  • Keep tests independent โ€” no execution order dependencies
  • Use @DisplayName for non-obvious test names
  • Assert on behavior, not on implementation details

MUST NOT DO

  • Do not use Thread.sleep() โ€” use @Timeout or polling utilities
  • Do not share mutable fields between test methods without @BeforeEach reset
  • Do not skip @AfterEach cleanup 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

  • mockito-expert
  • testcontainers-expert
  • java-expert
  • code-reviewer