JUnit 5 with AssertJ and Mockito skill for Codex

Jupiter tests that are discovered, strict and fluent. Codex writes Java and Kotlin tests on the JUnit Platform with the build wiring checked first, so tests are actually discovered. It uses AssertJ for readable failures, keeps Mockito strict stubs on, avoids @InjectMocks surprises, enables parallel execution safely, and migrates JUnit 4 suites step by step with the Vintage engine as a bridge.

  • Testing & QA
  • $junit-5
  • 238 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- junit-5

Installs to ~/.agents/skills/junit-5/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $junit-5, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes JVM tests with JUnit Jupiter (JUnit 5.x and 6.x), AssertJ and Mockito for Java and Kotlin - nested and parameterized tests, MockitoExtension with strict stubs, argument captors, recursive comparison, TempDir, tags, parallel execution, and Maven Surefire or Gradle useJUnitPlatform wiring. Use when adding or fixing tests in a Maven or Gradle project, when tests are not discovered, Mockito throws UnnecessaryStubbingException, or a JUnit 4 suite needs migrating.

Use it when

  • A Maven or Gradle project needs new or better tests
  • Tests are not discovered or the count dropped
  • Mockito strict stubbing errors or confusing mock injection
  • Moving from JUnit 4 or enabling parallel test execution

Use something else when

  • You need real databases or brokers in JVM tests: use testcontainers
  • The question is Spring Boot application structure rather than tests: use spring-boot

How to check the result

The build's reported test count matches or exceeds the previous run, new tests run alone via -Dtest or --tests, and no UnnecessaryStubbingException or Mockito agent warning appears.

The mistake it prevents

Running an old Surefire or forgetting useJUnitPlatform in Gradle, which makes the build pass while executing zero Jupiter tests.

~/.agents/skills/junit-5/SKILL.md

--- name: junit-5 description: Writes JVM tests with JUnit Jupiter (JUnit 5.x and 6.x), AssertJ and Mockito for Java and Kotlin - nested and parameterized tests, MockitoExtension… license: MIT ---

Done well: every test is discovered by the build, reads as a behavior in its display name, asserts with AssertJ’s fluent messages, stubs only what the test needs (strict stubs on), and runs green in parallel.

Inspect first

  1. Read AGENTS.md, then the build file: pom.xml or build.gradle(.kts). Find the JUnit BOM version, Mockito version, AssertJ, and whether Spring Boot manages them.
  2. Check the Java toolchain. JUnit 6 (released 2025) requires Java 17+ and keeps the same org.junit.jupiter.api packages; everything below applies to 5.10+ and 6.x.
  3. Look for leftover JUnit 4 (org.junit.Test, @RunWith). Mixed suites need the Vintage engine to run the old tests.
  4. Run the existing suite once: ./mvnw test or ./gradlew test.

Build wiring

Gradle (Kotlin DSL):

dependencies {
    testImplementation(platform("org.junit:junit-bom:5.13.4"))   // or a 6.x BOM on Java 17+
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    testImplementation("org.assertj:assertj-core:3.27.3")
    testImplementation("org.mockito:mockito-junit-jupiter:5.18.0")
}

tasks.test {
    useJUnitPlatform { excludeTags("slow") }
}

Maven: a recent Surefire (3.x) detects the JUnit Platform automatically once junit-jupiter is on the test classpath. Old Surefire 2.x runs zero tests silently. Version numbers above are examples; use the latest the project’s BOM or Spring Boot parent allows instead of hard-coding older ones.

Mockito 5 on JDK 21+ prints a warning about dynamically loading its agent. Configure it as a Java agent in the test JVM (see the Mockito docs for the Surefire argLine and Gradle jvmArgs snippet) rather than ignoring it, since future JDKs disallow dynamic loading.

Test structure

@DisplayName("ShippingQuote")
class ShippingQuoteTest {

    private final RateTable rates = RateTable.defaults();
    private final ShippingQuoter quoter = new ShippingQuoter(rates);

    @Nested
    @DisplayName("for domestic orders")
    class Domestic {

        @Test
        @DisplayName("is free from 100 EUR")
        void freeAtThreshold() {
            var quote = quoter.quote(cart(new BigDecimal("100.00")), Country.DE);

            assertThat(quote.cost()).isEqualByComparingTo("0");
            assertThat(quote.reason()).isEqualTo(Reason.FREE_THRESHOLD);
        }
    }
}
  • Test classes and methods can be package-private; no public needed.
  • A new instance per test method is the default, so fields are fresh for each test.
  • Use @Nested for groups sharing setup; @BeforeEach in a nested class runs after the outer one.

Parameterized tests

@ParameterizedTest(name = "{0} -> {1}")
@CsvSource(textBlock = """
    5m,     300
    1h30m,  5400
    0s,     0
    """)
void parsesDurations(String input, long expectedSeconds) {
    assertThat(Durations.parse(input).toSeconds()).isEqualTo(expectedSeconds);
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void rejectsBlankInput(String input) {
    assertThatThrownBy(() -> Durations.parse(input))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageContaining("blank");
}

static Stream<Arguments> invoices() {
    return Stream.of(
        arguments(invoice(30), false),
        arguments(invoice(31), true));
}

@ParameterizedTest
@MethodSource("invoices")
void overdueAfter30Days(Invoice invoice, boolean overdue) { ... }

@MethodSource factories must be static unless the class uses @TestInstance(Lifecycle.PER_CLASS); in Kotlin put them in a companion object with @JvmStatic.

AssertJ patterns

assertThat(orders)
    .extracting(Order::id, Order::status)
    .containsExactly(tuple("o1", PAID), tuple("o2", PENDING));

assertThat(actualUser)
    .usingRecursiveComparison()
    .ignoringFields("id", "createdAt")
    .isEqualTo(expectedUser);

assertSoftly(softly -> {
    softly.assertThat(resp.status()).isEqualTo(201);
    softly.assertThat(resp.body().id()).isNotBlank();
});
  • Prefer AssertJ over Assertions.assertEquals: messages show both values and context.
  • isEqualByComparingTo for BigDecimal (scale-insensitive); isEqualTo fails on 1.0 vs 1.00.
  • A bare assertThat(x); with no matcher asserts nothing. Static analysis (Error Prone, SpotBugs) catches it; reviewers should too.

Mockito

@ExtendWith(MockitoExtension.class)
class RegistrationServiceTest {

    @Mock UserRepository users;
    @Mock Mailer mailer;
    @InjectMocks RegistrationService service;
    @Captor ArgumentCaptor<Email> email;

    @Test
    void sendsWelcomeEmailOnce() {
        given(users.save(any())).willAnswer(inv -> inv.getArgument(0));

        service.register("[email protected]");

        then(mailer).should().send(email.capture());
        assertThat(email.getValue().to()).isEqualTo("[email protected]");
        then(mailer).shouldHaveNoMoreInteractions();
    }
}
  • MockitoExtension uses strict stubs: an unused when(...) fails with UnnecessaryStubbingException. Delete the stub; do not switch to lenient globally. Use lenient().when(...) only for a shared stub some tests legitimately skip.
  • Mockito 5 uses the inline mock maker by default, so final classes and methods can be mocked. That makes it easy to mock value objects; do not. Build real ones.
  • Verify commands (side effects), not queries. Stubbing and verifying the same call is redundant.
  • @InjectMocks silently falls back between constructor, setter and field injection. Prefer constructing the subject yourself in a field initializer or @BeforeEach.
  • Kotlin: prefer MockK (every { }, verify { }), or mockito-kotlin for whenever since when is a keyword.

Files, time and tags

@Test
void writesReport(@TempDir Path dir) throws IOException {
    new ReportWriter(dir).write(sample());
    assertThat(dir.resolve("report.csv")).content().startsWith("id,total");
}
  • Inject java.time.Clock and pass Clock.fixed(Instant.parse("2026-03-01T00:00:00Z"), ZoneOffset.UTC).
  • @Tag("slow"), @Tag("integration") and filter in the build, not with @Disabled.
  • @Disabled needs a reason string and a ticket; otherwise delete the test.

Parallel execution

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

Mark tests touching shared resources with @ResourceLock("db") or @Isolated. Turn parallelism on only after the suite passes in random method order (junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random).

Spring Boot notes

  • Slice tests (@WebMvcTest, @DataJpaTest) over @SpringBootTest for speed.
  • Replace beans with @MockitoBean (Spring Framework 6.2+); @MockBean is deprecated.
  • Every distinct mocked-bean set creates a new application context; keep them consistent across test classes so the context cache is reused.

Running

./mvnw test -Dtest='ShippingQuoteTest#freeAtThreshold'
./mvnw test -Dgroups=fast -DexcludedGroups=slow
./gradlew test --tests 'com.acme.shipping.ShippingQuoteTest'
./gradlew test --tests '*ShippingQuote*' --info

Migrating from JUnit 4

  • org.junit.Test -> org.junit.jupiter.api.Test; @Before -> @BeforeEach; @BeforeClass -> @BeforeAll; @Ignore -> @Disabled
  • @RunWith(MockitoJUnitRunner.class) -> @ExtendWith(MockitoExtension.class)
  • @Rule TemporaryFolder -> @TempDir; ExpectedException -> assertThatThrownBy
  • Parameterized runner -> @ParameterizedTest + sources
  • Keep junit-vintage-engine until the last JUnit 4 test is gone, then remove it
  • OpenRewrite has a JUnit 4 to 5 recipe for large codebases (needs the plugin download)

Verification

  1. Run the build’s test task and compare the reported test count to before; a drop means discovery broke.
  2. Run new tests alone with -Dtest or --tests.
  3. No UnnecessaryStubbingException, no Mockito agent warning, no skipped tests without a reason.
  4. Break the behavior once and read the AssertJ failure: it should explain itself.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "JUnit 5 with AssertJ and Mockito"
  short_description: "Jupiter tests that are discovered, strict and fluent"
  default_prompt: "$junit-5 Write JUnit 5 tests with Mockito for RegistrationService"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc