jvm-unit-tests
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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill jvm-unit-testsjvm-unit-tests
Overview
Per junit.org/junit5/docs/current/user-guide (opens in new window):
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).
Choosing a framework
| Language | Framework | Why |
|---|---|---|
| Java (new project) | JUnit 5 | The JVM standard; starter templates for Maven and Gradle (j5-ug (opens in new window)) |
| Kotlin (Kotlin-only) | Kotest | Kotlin-idiomatic DSL, matchers, coroutines (kotest.io (opens in new window)) → 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 (opens in new window)) → references/spock.md |
| Scala | ScalaTest | "the most flexible and most popular testing tool in the Scala ecosystem" (scalatest.org (opens in new window)) → 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.
Step 1 - Install (Maven / Gradle)
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()
}Step 2 - First test
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.
Step 3 - Lifecycle annotations
Per j5-ug (opens in new window): @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.
Step 4 - Parameterized tests
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.
Step 5 - Extensions (@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.
Step 6 - Display names + conditional execution
@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() { ... }
}Step 7 - Parallel execution
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.
Step 8 - Coverage and CI
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).
Authoring conventions
When authoring a new unit test in an existing project:
Anti-patterns
| 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 |
Limitations
References
AssertJ - fluent JVM assertions (reference)
View source (opens in new window)AssertJ - fluent JVM assertions (reference)
Companion reference for jvm-unit-tests. AssertJ is "a Java library that provides a rich set of assertions and truly helpful error messages, improves test code readability, and is designed to be super easy to use within your favorite IDE" (assertj.github.io/doc (opens in new window)). It pairs with JUnit 5, TestNG, or Spock - AssertJ handles the assertion layer; the framework handles the runner. This covers assertj-core (JDK types).
Use when tests need richer failure messages than built-in assertEquals / assertTrue, deep equality without equals overrides, or multi-failure collection. Bonus: assertThat(actual).isEqualTo(expected) sidesteps the JUnit-vs-TestNG argument-order trap entirely.
Install
Maven: org.assertj:assertj-core:3.27.7 (test scope). Gradle: testImplementation("org.assertj:assertj-core:3.27.7"). Check Maven Central for the latest release before copying. Then static-import once per test file:
import static org.assertj.core.api.Assertions.*;assertThat entry point
assertThat(actual) returns a type-specific assertion object; everything chains fluently (basic assertions (opens in new window)):
assertThat(frodo.getName()).isEqualTo("Frodo");
assertThat("text").isNotNull()
.startsWith("te")
.contains("ex");
assertThat(value).isSameAs(ref); // reference equality
assertThat(value).isInstanceOf(MyClass.class);
assertThat(flag).isTrue();
assertThat(user.getAge()).as("user age").isGreaterThan(0); // labeledCollection assertions
Per collection assertions (opens in new window):
assertThat(list).hasSize(9);
assertThat(list).contains(frodo, sam); // any order, subset
assertThat(list).containsExactly(frodo, sam, pippin); // exact order + set
assertThat(list).containsOnly(frodo, sam); // any order, exact set
assertThat(list).doesNotContain(sauron);
// Element-level verification
assertThat(hobbits).allSatisfy(c -> {
assertThat(c.getRace()).isEqualTo(HOBBIT);
});
assertThat(hobbits).anySatisfy(c ->
assertThat(c.getName()).isEqualTo("Sam"));
// Extraction - single property, or tuples for several
assertThat(fellowship).extracting("name")
.contains("Boromir", "Gandalf", "Frodo");
assertThat(fellowship).extracting("name", "age")
.contains(tuple("Boromir", 37), tuple("Sam", 38));
// Filter before asserting
assertThat(fellowship).filteredOn(c -> c.getName().contains("o"))
.containsOnly(aragorn, frodo);Exception assertions
Per exception assertions (opens in new window):
// Primary form
assertThatThrownBy(() -> parser.parse(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("null input");
// Type-first form
assertThatExceptionOfType(IOException.class)
.isThrownBy(() -> { throw new IOException("boom!"); })
.withMessage("%s!", "boom")
.withNoCause();
// BDD form - separates WHEN from THEN
Throwable thrown = catchThrowable(() -> names[9]);
assertThat(thrown).isInstanceOf(ArrayIndexOutOfBoundsException.class)
.hasMessageContaining("9");
// Cause-chain inspection
assertThat(thrown).hasCauseInstanceOf(NullPointerException.class);
assertThat(thrown).hasRootCauseInstanceOf(SocketException.class);
// Assert no exception
assertThatCode(() -> service.process(input)).doesNotThrowAnyException();Always add a message check - a type-only assertion passes for any exception of that type.
SoftAssertions
Per soft assertions (opens in new window) - collect failures instead of stopping at the first; all violations report together. Prefer the static helper (calls assertAll() on exit):
assertSoftly(softly -> {
softly.assertThat(frodo.getName()).isEqualTo("Frodo");
softly.assertThat(frodo.getAge()).isEqualTo(33);
softly.assertThat(frodo.getRace()).isEqualTo(HOBBIT);
});With the instance form (new SoftAssertions()), a skipped assertAll() silently swallows failures.
Recursive comparison
Per recursive comparison (opens in new window) - field-by-field object-graph equality without equals overrides:
assertThat(sherlock).usingRecursiveComparison()
.isEqualTo(sherlockClone);
// Exclude volatile fields
assertThat(actual).usingRecursiveComparison()
.ignoringFields("id", "home.address.street")
.ignoringFieldsMatchingRegexes(".*At", ".*Id")
.isEqualTo(expected);
// Other variants
.ignoringActualNullFields()
.withEqualsForType((d1, d2) -> Math.abs(d1 - d2) <= 0.5, Double.class)
.withStrictTypeChecking()Custom assertions
Per custom assertions (opens in new window) - extend AbstractAssert<SELF, ACTUAL> and expose a static factory:
public class PersonAssert extends AbstractAssert<PersonAssert, Person> {
public PersonAssert(Person actual) { super(actual, PersonAssert.class); }
public PersonAssert hasName(String name) {
isNotNull();
if (!actual.getName().equals(name)) {
failWithMessage("Expected name <%s> but was <%s>", name, actual.getName());
}
return this;
}
}
public static PersonAssert assertThat(Person actual) {
return new PersonAssert(actual);
}Usage reads like built-ins: assertThat(person).hasName("Alice").
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Mix assertEquals and assertThat styles in one suite | Inconsistent failure messages | Pick one style, lint-enforce |
| Recursive comparison without excluding volatile fields | Timestamps/IDs differ per run | ignoringFieldsMatchingRegexes(".*At", ".*Id") |
SoftAssertions instance without assertAll() | Failures silently swallowed | assertSoftly() helper |
assertThat(flag).isEqualTo(true) | Loses semantic failure message | isTrue() / isFalse() |
| Exception assertion without message check | Passes for any exception of the type | .hasMessageContaining(...) |
Limitations
References
Kotest - Kotlin-native testing (reference)
View source (opens in new window)Kotest - Kotlin-native testing (reference)
Companion reference for jvm-unit-tests. Consult for Kotlin-only or Kotlin-primary projects that want a Kotlin-idiomatic DSL over JUnit 5's annotation-driven approach. For multi-language JVM projects, JUnit 5 is more universal (SKILL.md).
Per kotest.io/docs (opens in new window):
Kotest differs from JUnit 5 by: multiple specification styles (DSL choice per team), rich matchers (shouldBe, shouldContain, shouldThrow), built-in property-based testing, coroutines-first test bodies, and spec-level isolation modes. Matchers are bundled with the runner and are not a drop-in standalone assertion library - for assertion-only use with JUnit 5 / TestNG / Spock see assertj.md (opens in new window).
Install
build.gradle.kts:
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
testImplementation("io.kotest:kotest-property:5.9.1") // for property-based
}
tasks.test {
useJUnitPlatform()
}Specification styles
Per kt-docs (opens in new window), 8+ styles. Common picks - one style per project:
StringSpec (terse, no nesting):
class CalculatorTest : StringSpec({
"adds two numbers" {
Calculator.add(1, 2) shouldBe 3
}
"throws on overflow" {
shouldThrow<ArithmeticException> {
Calculator.add(Int.MAX_VALUE, 1)
}
}
})FunSpec (most familiar to JUnit / pytest users):
class CalculatorTest : FunSpec({
test("adds two numbers") {
Calculator.add(1, 2) shouldBe 3
}
context("overflow handling") {
test("throws on max + 1") {
shouldThrow<ArithmeticException> { Calculator.add(Int.MAX_VALUE, 1) }
}
}
})BehaviorSpec (Given/When/Then BDD): given("…") { \when`("…") { then("…") { … } } }`.
Matchers
Per kotest.io/docs/assertions/matchers.html (opens in new window):
| Matcher | Use |
|---|---|
value shouldBe expected / shouldNotBe | Equality |
value.shouldBeNull() / shouldNotBeNull() | Null check |
string.shouldContain("substring") / shouldStartWith / shouldEndWith | String |
string.shouldMatch(regex) | Regex |
list.shouldHaveSize(3) / shouldContainExactly(...) / shouldContainAll(...) | Collection |
map.shouldContainKey("key") / shouldContainValue("v") | Map |
value.shouldBeInstanceOf<MyClass>() | Type |
result.shouldBeSuccess() / shouldBeFailure() | Kotlin Result |
shouldThrow<E> { ... } | Exception |
Property-based testing (built-in)
"addition is commutative" {
checkAll<Int, Int> { a, b ->
a + b shouldBe b + a
}
}
"concatenation length" {
checkAll(Arb.string(), Arb.string()) { a, b ->
(a + b).length shouldBe a.length + b.length
}
}For deeper property-based work see the qa-property-based plugin (jqwik-testing for the JVM). Don't run Kotest property-based and jqwik in the same project - pick one.
Coroutines and data-driven tests
Test bodies are suspend functions - runTest etc. from kotlinx-coroutines-test work directly:
"fetches user data" {
val user = fetchUserAsync(1) // suspend function
user.id shouldBe 1
}Data-driven rows via withData - each row reports as a separate test:
context("addition") {
withData(
Triple(1, 2, 3),
Triple(0, 0, 0),
Triple(-1, 1, 0),
) { (a, b, expected) ->
(a + b) shouldBe expected
}
}Isolation modes
Per kotest.io/docs/framework/isolation-mode.html (opens in new window):
| Mode | Behavior |
|---|---|
SingleInstance | One spec instance for all tests (default; fastest) |
InstancePerTest | Fresh spec instance per test (incl. nested contexts) |
InstancePerLeaf | Fresh spec instance per leaf-test only |
Set per-spec (isolationMode = IsolationMode.InstancePerTest inside the spec body) or globally via AbstractProjectConfig. Use a fresh-instance mode whenever specs carry mutable state.
CI
Kotest's runner is kotest-runner-junit5, so CI is identical to JUnit 5: ./gradlew test jacocoTestReport.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mix multiple spec styles in one project | Reader confusion | Pick one |
| Default isolation + shared mutable state | Tests interfere | InstancePerTest |
assertEquals(a, b) (JUnit style) in Kotest specs | Mixes paradigms | a shouldBe b |
References
ScalaTest - Scala-native testing (reference)
View source (opens in new window)ScalaTest - Scala-native testing (reference)
Companion reference for jvm-unit-tests. Consult for Scala projects (Scala 2.13 or 3) or existing ScalaTest codebases. For Java/Kotlin projects, JUnit 5 (SKILL.md) or Kotest (kotest.md (opens in new window)) are more idiomatic.
Per scalatest.org (opens in new window):
ScalaTest is the de facto Scala testing framework, with multiple specification styles and a Matchers DSL; ScalaCheck is its canonical property-based pairing.
Install
build.sbt:
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % Test
libraryDependencies += "org.scalatestplus" %% "scalacheck-1-17" % "3.2.18.0" % TestTest files live under src/test/scala/.
Specification styles
Per scalatest.org/user_guide/selecting_a_style (opens in new window) - pick one style per project:
FlatSpec (BDD-style, the recommended default):
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class CalculatorSpec extends AnyFlatSpec with Matchers {
"Calculator" should "add two numbers" in {
Calculator.add(1, 2) should equal(3)
}
it should "throw on overflow" in {
an [ArithmeticException] should be thrownBy {
Calculator.add(Int.MaxValue, 1)
}
}
}FunSuite (xUnit-style): test("add two numbers") { assert(Calculator.add(1, 2) == 3) }. WordSpec (deeply-nested BDD): "A UserService" when { "creating a user" should { "set default role" in { … } } }.
Matchers DSL
Per scalatest.org/user_guide/using_matchers (opens in new window):
result should equal(42)
result shouldBe 42 // strict equality (uses ==)
list should have size 5
list should contain("alice")
list should contain only("alice", "bob")
list should contain inOrder("alice", "bob")
map should contain key("alice")
string should startWith("hello")
string should fullyMatch regex("\\d+")
opt shouldBe defined
either shouldBe Right(42)
result should be > 10
result should be (within(1.0) of 42.0) // float toleranceAsync tests
AsyncFlatSpec test bodies return Future[Assertion] - ScalaTest handles the async lifecycle:
class AsyncSpec extends AsyncFlatSpec with Matchers {
"fetchUser" should "return user data" in {
fetchUser(1) map { user => user.id shouldBe 1 }
}
}Sync test bodies for async code are a silent-pass trap - the Future never resolves inside the assertion.
ScalaCheck integration
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import org.scalacheck.Gen
class PropertyCheckSpec extends AnyFlatSpec with Matchers
with ScalaCheckPropertyChecks {
"addition" should "be commutative" in {
forAll { (a: Int, b: Int) => a + b shouldBe b + a }
}
}forAll shrinks to a minimal counterexample on failure (scalacheck.org). For the property-based discipline see the qa-property-based plugin.
Lifecycle hooks and fixtures
BeforeAndAfterAll (beforeAll / afterAll) + BeforeAndAfter (before { … } / after { … }), or the loan-fixture pattern:
def withDatabase(test: Database => Unit): Unit = {
val db = createTestDb()
try test(db)
finally db.close()
}
"createUser" should "persist to db" in withDatabase { db => ... }Tagged tests
object Slow extends Tag("Slow")
"slow operation" should "work" taggedAs Slow in { ... }Selective run: sbt 'testOnly * -- -n Slow' (include) / -l Slow (exclude).
CI
- run: sbt clean coverage test coverageReportCoverage via the sbt-scoverage plugin (Scala-native, not JaCoCo); cross-language projects need JaCoCo separately for the Java/Kotlin side.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mix specification styles in one project | Reader confusion | Pick one |
assert(x == y) instead of Matchers DSL | Loses diff on failure | x should equal(y) |
| Sync test bodies for async code | Future never resolves; false pass | AsyncFlatSpec |
References
Spock - Groovy BDD testing (reference)
View source (opens in new window)Spock - Groovy BDD testing (reference)
Companion reference for jvm-unit-tests. Consult for Groovy projects or existing Spock codebases. Pure-Java projects gain little vs JUnit 5 (Spock requires the Groovy compiler in the build); Kotlin teams get more from Kotest (kotest.md (opens in new window)).
Per spockframework.org/spock/docs (opens in new window):
Spock's distinguishing features: given/when/then BDD blocks with implicit assertions, where: data tables (the cleanest parametrization syntax on the JVM), and built-in mocking (Mock() / Stub() / Spy() - no Mockito needed) with declarative interaction verification.
Install
build.gradle.kts:
plugins {
id("groovy") // Groovy plugin needed for Spock
}
dependencies {
testImplementation("org.spockframework:spock-core:2.4-M5-groovy-4.0")
testImplementation(platform("org.junit:junit-bom:5.11.0"))
testImplementation("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform() // Spock 2 runs on JUnit Platform
}Test files: src/test/groovy/**/*Spec.groovy.
Blocks
Per sp-docs (opens in new window):
| Block | Purpose |
|---|---|
setup: / given: | Test fixture setup |
when: | Action being tested |
then: | Assertions on the action's effect (each statement is implicitly a boolean assertion) |
expect: | Combined when+then for simple cases |
where: | Data table for parametrized tests |
cleanup: | Per-test cleanup |
and: | Subdivider for any block |
import spock.lang.Specification
class CalculatorSpec extends Specification {
def "adds two numbers"() {
given:
def calc = new Calculator()
when:
def result = calc.add(1, 2)
then:
result == 3
}
}Failure output shows the full expression value, not just true/false.
Data tables
def "addition cases"() {
expect:
Calculator.add(a, b) == result
where:
a | b || result
1 | 2 || 3
0 | 0 || 0
-1 | 1 || 0
}Each row runs as a separate test; failures don't stop subsequent rows. Exception cases pair thrown() with a piped input list:
then:
InvalidEmailException ex = thrown()
where:
email << ["", "no-at-sign", "@no-domain"]Built-in mocking
def "user service calls repository"() {
given:
def repo = Mock(UserRepository)
def service = new UserService(repo)
when:
service.create("alice@example.com")
then:
1 * repo.save(_) // exactly 1 call to save() with any arg
}
def "service handles repo failure"() {
given:
def repo = Stub(UserRepository) {
save(_) >> { throw new SQLException("connection lost") }
}
...
then:
thrown(SQLException)
}Mock vs Stub vs Spy: Mock() - verifiable interactions; Stub() - no verification, default-value responses unless instructed; Spy() - wraps a real object, observe + optionally override.
Interaction cardinality: 1 * (exactly once), 0 * (never), (1..3) *, 1 * m(_) (any args), 1 * m() >> 42 (stubbed return), 1 * m() >>> [1, 2, 3] (successive returns).
Lifecycle hooks
setupSpec() / cleanupSpec() (once per spec) and setup() / cleanup() (per test).
CI
Spock 2 runs on the JUnit Platform - same shape as JUnit 5: ./gradlew test jacocoTestReport.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mockito alongside Spock | Two mocking APIs in one suite | Spock's built-in mocking |
expect: for multi-step setup | Mixes given + when + then | Explicit given/when/then |
_ * cardinality everywhere | Loses interaction-count check | Specify 1 * etc. |
| Spock for a Java-only project | Groovy adds classpath weight | JUnit 5 (SKILL.md) |
expect: + where: with no condition expression on the line | Bare statements pass silently | Make the line an expression Groovy evaluates as the assertion |
Limitations
References
TestNG - legacy JVM testing (reference)
View source (opens in new window)TestNG - legacy JVM testing (reference)
Companion reference for jvm-unit-tests. Consult when maintaining a legacy TestNG codebase, a Selenium-tradition project (TestNG is common in that ecosystem), or the rare legitimate test-method-dependency case. For new code, prefer JUnit 5 (SKILL.md).
Per testng.org (opens in new window), TestNG (~2004) was the original JUnit-improvement project. Its distinguishing features - method dependencies (dependsOnMethods / dependsOnGroups), test groups, suite XML config, @DataProvider parametrization - have mostly been adopted by JUnit 5 since.
Install
Gradle: testImplementation("org.testng:testng:7.10.2") + tasks.test { useTestNG() }. Maven: org.testng:testng:7.10.2 with test scope.
Worked example - @Test + @DataProvider
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
public class CalculatorTest {
@Test
public void addsTwoNumbers() {
assertEquals(Calculator.add(1, 2), 3);
}
@DataProvider(name = "addCases")
public Object[][] addCases() {
return new Object[][] {
{1, 2, 3},
{0, 0, 0},
{-1, 1, 0},
};
}
@Test(dataProvider = "addCases")
public void testAdd(int a, int b, int expected) {
assertEquals(Calculator.add(a, b), expected);
}
}TestNG's assertEquals signature is (actual, expected) - reversed from JUnit. Reversed arguments produce misleading failure diagnostics; this is the top migration bug source. A @DataProvider can live in a separate class via dataProviderClass = TestData.class.
Lifecycle annotations
Per tn-docs (opens in new window): @BeforeSuite / @AfterSuite, @BeforeClass / @AfterClass, @BeforeMethod / @AfterMethod, @BeforeGroups / @AfterGroups.
Priorities and dependencies
@Test(priority = 1)
public void firstTest() { ... }
@Test(dependsOnMethods = "createUser")
public void updateUser() { /* only runs if createUser passed */ }Dependencies are a smell in unit tests (each should be independent); legitimate for stage-gated integration suites (create → modify → delete). Use sparingly - one failure cascades down the chain.
Groups and selective runs
@Test(groups = "fast")
public void fastTest1() { ... }
@Test(groups = {"slow", "integration"})
public void slowIntegration() { ... }Run selectively: mvn test -Dgroups=fast or via a suite XML.
testng.xml suites
<suite name="MySuite" parallel="methods" thread-count="4">
<test name="FastTests">
<groups>
<run>
<include name="fast"/>
<exclude name="integration"/>
</run>
</groups>
<classes>
<class name="com.example.CalculatorTest"/>
</classes>
</test>
</suite>Run via mvn test -Dsurefire.suiteXmlFiles=testng.xml. Suite XML adds complexity - most grouping can be done with annotations; document suite intent if you keep the XML.
Listeners
public class CustomListener implements ITestListener {
@Override
public void onTestFailure(ITestResult result) {
// capture screenshot, log context, etc.
}
}Apply per-class with @Listeners(CustomListener.class).
CI
./gradlew test (with useTestNG()) or mvn test -Dsurefire.suiteXmlFiles=testng.xml. JaCoCo coverage works identically to JUnit setups.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
assertEquals(expected, actual) (JUnit order) | TestNG order is reversed; misleading diffs | assertEquals(actual, expected), or AssertJ (assertj.md (opens in new window)) |
Heavy dependsOnMethods chains | Order coupling; cascade failures | Independent tests + setup methods |
| Mixing TestNG + JUnit in one project | Two runners | Pick one |