JVM unit testing (Java / Kotlin / Scala / Groovy) with JUnit 5 (Jupiter) as the primary framework - annotations (`@Test` / `@ParameterizedTest` / source providers), lifecycle hooks (`@BeforeAll` / `@BeforeEach`), extension model (`@ExtendWith` + Mockito/Spring), display names, conditional execution, parallel-execution config, JaCoCo coverage, and Maven Surefire / Gradle CI. Includes a per-language framework decision table (Java → JUnit 5, Kotlin → Kotest, Groovy → Spock, Scala → ScalaTest, legacy → TestNG; always match an existing build convention) and test-authoring conventions (framework detection from pom.xml / build.gradle / build.sbt, path conventions, no fabricated methods). References cover Kotest spec styles, Spock given/when/then + data tables, TestNG DataProviders + suites, ScalaTest styles + Matchers, and the AssertJ fluent-assertion catalog. Use for any JVM unit-test task: choosing or configuring a framework, writing or parameterizing tests, wiring coverage and CI.
72
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Per junit.org/junit5/docs/current/user-guide:
JUnit 5 (released 2017, replacing JUnit 4) has three components:
This skill targets JUnit Jupiter as the JVM default, with the
language-specific alternatives as references. Lifecycle scope
(configure / run / parameterize / coverage / CI); test code hygiene is in
test-code-conventions (qa-test-review).
junit-jupiter → JUnit 5; io.kotest:kotest-runner-junit5
→ Kotest; org.spockframework:spock-core → Spock;
org.scalatest:scalatest → ScalaTest; org.testng:testng → TestNG. If
exactly one is present, match it - switching frameworks mid-build
multiplies CI complexity for no quality gain.| Language | Framework | Why |
|---|---|---|
| Java (new project) | JUnit 5 | The JVM standard; starter templates for Maven and Gradle (j5-ug) |
| Kotlin (Kotlin-only) | Kotest | Kotlin-idiomatic DSL, matchers, coroutines (kotest.io) → references/kotest.md |
| Kotlin + Java modules | JUnit 5 | Cross-language support; one runner for both |
| Groovy | Spock | "a testing and specification framework for Java and Groovy applications" (spockframework.org) → references/spock.md |
| Scala | ScalaTest | "the most flexible and most popular testing tool in the Scala ecosystem" (scalatest.org) → references/scalatest.md |
| Java (legacy TestNG codebase) | TestNG | Match the existing convention; method dependencies + suite XML → references/testng.md |
Language detection from the build file: build.sbt / scalaVersion →
Scala; kotlin("jvm") plugin / kotlin-stdlib → Kotlin; id("groovy")
with no Kotlin plugin → Groovy; otherwise Java. Do not pick Spock for a
Java-only project (it drags in the Groovy compiler) or ScalaTest for
Java/Kotlin.
For richer assertions on JUnit 5 / TestNG / Spock, pair with AssertJ → references/assertj.md.
Maven pom.xml:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>Gradle build.gradle.kts:
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
}
tasks.test {
useJUnitPlatform()
}import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
void addsTwoNumbers() {
assertEquals(3, Calculator.add(1, 2));
}
}Run: mvn test or ./gradlew test.
Per j5-ug: @BeforeAll / @AfterAll (static, once per class) and
@BeforeEach / @AfterEach (per test). JUnit 4's @Before / @After are
ignored by the Jupiter engine - a silent migration trap.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ParametrizedTest {
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
})
void addCases(int a, int b, int expected) {
assertEquals(expected, Calculator.add(a, b));
}
@ParameterizedTest
@MethodSource("addProvider")
void addsViaMethodSource(int a, int b, int expected) {
assertEquals(expected, Calculator.add(a, b));
}
static Stream<Arguments> addProvider() {
return Stream.of(Arguments.of(1, 2, 3), Arguments.of(0, 0, 0));
}
}Source providers: @ValueSource, @CsvSource, @CsvFileSource,
@MethodSource, @EnumSource, @ArgumentsSource. Each row reports as its
own test.
@ExtendWith)The extension model replaces JUnit 4's @Rule / @RunWith:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository repo;
@InjectMocks
private UserService service;
@Test
void createsUser() {
when(repo.save(any())).thenReturn(new User(1, "Alice"));
User u = service.create("Alice");
assertEquals(1, u.getId());
}
}Common extensions: MockitoExtension, SpringExtension,
SystemStubsExtension, TempDirectory.
@DisplayName("User service")
class UserServiceTest {
@Test
@DisplayName("creates a user with email lowercased")
void createsUserWithLowercaseEmail() { ... }
@Test
@EnabledOnOs(OS.LINUX)
void linuxOnlyTest() { ... }
@Test
@EnabledIfEnvironmentVariable(named = "INTEGRATION", matches = "true")
void integrationOnly() { ... }
@Test
@Disabled("Re-enable after fixing JIRA-1234")
void temporarilyDisabled() { ... }
}junit-platform.properties:
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.config.strategy = dynamicPer-class opt-out: @Execution(ExecutionMode.SAME_THREAD). Parallel
execution requires test independence; shared mutable state breaks it.
JaCoCo, Maven (jacoco-maven-plugin 0.8.12): bind prepare-agent, a
report execution in the test phase, and a check execution with a
BUNDLE / LINE / COVEREDRATIO minimum (e.g. 0.80) to gate coverage.
Gradle + GitHub Actions:
- run: ./gradlew test jacocoTestReport
- uses: codecov/codecov-action@v4
with: { files: ./build/reports/jacoco/test/jacocoTestReport.xml }Surefire (Maven) emits JUnit XML for junit-xml-analysis
(qa-test-reporting). Kotest and Spock 2 run on the JUnit Platform, so the
same ./gradlew test jacocoTestReport CI shape applies; ScalaTest uses
sbt clean coverage test coverageReport (sbt-scoverage, not JaCoCo).
When authoring a new unit test in an existing project:
src/main/java|kotlin|scala|groovy →
language; pom.xml → Maven, build.gradle[.kts] → Gradle, build.sbt
→ sbt. Test sources go under src/test/<language>/
(docs.gradle.org/java_testing).src/test/java/<package>/<Class>Test.java,
src/test/kotlin/<package>/<Class>Test.kt,
src/test/scala/<package>/<Class>Spec.scala,
src/test/groovy/<package>/<Class>Spec.groovy. One spec → one new test
method; never modify existing tests, never fabricate target methods the
spec did not state.(expected, actual);
TestNG flips it to (actual, expected) - reversed arguments produce
misleading diffs. When AssertJ is on the classpath, prefer
assertThat(actual).isEqualTo(expected) - it sidesteps the order trap
entirely (references/assertj.md).assertTrue(true), Kotest 1 shouldBe 1, Spock
then: true) when the spec names a concrete return value.@DataProvider with JUnit 5
@ParameterizedTest will not be discovered - one framework's
parametrization API per file.| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mix JUnit 4 + JUnit 5 in the same project | Two runners, confusing | Jupiter; Vintage only for migration |
@Test from org.junit.Test (JUnit 4) | Doesn't run under Jupiter | Import org.junit.jupiter.api.Test (Step 2) |
JUnit 4 @Before / @After in a Jupiter project | Silently ignored | @BeforeEach / @AfterEach (Step 3) |
| Skip parallel-execution config | Slow suite at scale | Enable parallel.enabled (Step 7) |
@Disabled without a ticket reference | Forgotten disabled tests | Reason + issue link (Step 6) |
Generic assertTrue(x.equals(y)) | Loses diff on failure | assertEquals(x, y) or AssertJ |
| New framework mid-build "for modernization" | Wholesale rewrite for no quality gain | Match convention; scope migration separately |
test-code-conventions (qa-test-review) - test code hygiene