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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- junit-5 Installs to ~/.agents/skills/junit-5/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install junit-5 The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project junit-5 Installs to .agents/skills/junit-5/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/junit-5.zipunzip -o junit-5.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) junit-5 PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md, then the build file:pom.xmlorbuild.gradle(.kts). Find the JUnit BOM version, Mockito version, AssertJ, and whether Spring Boot manages them. - Check the Java toolchain. JUnit 6 (released 2025) requires Java 17+ and keeps the same
org.junit.jupiter.apipackages; everything below applies to 5.10+ and 6.x. - Look for leftover JUnit 4 (
org.junit.Test,@RunWith). Mixed suites need the Vintage engine to run the old tests. - Run the existing suite once:
./mvnw testor./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
publicneeded. - A new instance per test method is the default, so fields are fresh for each test.
- Use
@Nestedfor groups sharing setup;@BeforeEachin 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. isEqualByComparingToforBigDecimal(scale-insensitive);isEqualTofails on1.0vs1.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();
}
}
MockitoExtensionuses strict stubs: an unusedwhen(...)fails withUnnecessaryStubbingException. Delete the stub; do not switch to lenient globally. Uselenient().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.
@InjectMockssilently falls back between constructor, setter and field injection. Prefer constructing the subject yourself in a field initializer or@BeforeEach.- Kotlin: prefer MockK (
every { },verify { }), ormockito-kotlinforwheneversincewhenis 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.Clockand passClock.fixed(Instant.parse("2026-03-01T00:00:00Z"), ZoneOffset.UTC). @Tag("slow"),@Tag("integration")and filter in the build, not with@Disabled.@Disabledneeds 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@SpringBootTestfor speed. - Replace beans with
@MockitoBean(Spring Framework 6.2+);@MockBeanis 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 -
Parameterizedrunner ->@ParameterizedTest+ sources - Keep
junit-vintage-engineuntil 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
- Run the build’s test task and compare the reported test count to before; a drop means discovery broke.
- Run new tests alone with
-Dtestor--tests. - No
UnnecessaryStubbingException, no Mockito agent warning, no skipped tests without a reason. - 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.yaml
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.