Testland
Browse all skills & agents

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-tests
View source

jvm-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:

  • JUnit Jupiter - the modern programming + extension model
  • JUnit Vintage - backward-compat for JUnit 3/4 tests
  • JUnit Platform - runner foundation (also hosts Kotest, Spock 2)

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

  1. Match the existing convention first. Grep the build file for dependency tokens: 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.
  2. No convention yet - decide by primary source language:
LanguageFrameworkWhy
Java (new project)JUnit 5The JVM standard; starter templates for Maven and Gradle (j5-ug (opens in new window))
Kotlin (Kotlin-only)KotestKotlin-idiomatic DSL, matchers, coroutines (kotest.io (opens in new window)) → references/kotest.md
Kotlin + Java modulesJUnit 5Cross-language support; one runner for both
GroovySpock"a testing and specification framework for Java and Groovy applications" (spockframework.org (opens in new window)) → references/spock.md
ScalaScalaTest"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)TestNGMatch 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 = dynamic

Per-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:

  1. Detect language + build tool. 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 (opens in new window)).
  2. Detect the framework from the build file (dependency tokens above). Two or more framework signals in one build → stop and ask which to use.
  3. Emit one test file at the conventional path: 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.
  4. Mind assertion argument order. JUnit takes (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).
  5. No smoke asserts (assertTrue(true), Kotest 1 shouldBe 1, Spock then: true) when the spec names a concrete return value.
  6. Don't mix engines' APIs: TestNG @DataProvider with JUnit 5 @ParameterizedTest will not be discovered - one framework's parametrization API per file.

Anti-patterns

Anti-patternWhy it failsFix
Mix JUnit 4 + JUnit 5 in the same projectTwo runners, confusingJupiter; Vintage only for migration
@Test from org.junit.Test (JUnit 4)Doesn't run under JupiterImport org.junit.jupiter.api.Test (Step 2)
JUnit 4 @Before / @After in a Jupiter projectSilently ignored@BeforeEach / @AfterEach (Step 3)
Skip parallel-execution configSlow suite at scaleEnable parallel.enabled (Step 7)
@Disabled without a ticket referenceForgotten disabled testsReason + issue link (Step 6)
Generic assertTrue(x.equals(y))Loses diff on failureassertEquals(x, y) or AssertJ
New framework mid-build "for modernization"Wholesale rewrite for no quality gainMatch convention; scope migration separately

Limitations

  • JUnit 5 requires Java 8+ (current versions Java 17+ at runtime per j5-ug (opens in new window)).
  • Migration from JUnit 4 isn't fully automatic; rule → extension replacement is non-trivial.
  • Parallel execution requires test independence.

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);  // labeled

Collection 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-patternWhy it failsFix
Mix assertEquals and assertThat styles in one suiteInconsistent failure messagesPick one style, lint-enforce
Recursive comparison without excluding volatile fieldsTimestamps/IDs differ per runignoringFieldsMatchingRegexes(".*At", ".*Id")
SoftAssertions instance without assertAll()Failures silently swallowedassertSoftly() helper
assertThat(flag).isEqualTo(true)Loses semantic failure messageisTrue() / isFalse()
Exception assertion without message checkPasses for any exception of the type.hasMessageContaining(...)

Limitations

  • Targets Java 8+; Kotlin works but Kotlin users may prefer AssertK.
  • Cyclic object graphs in recursive comparison need care; it defaults to ignoring overridden equals.
  • String-name extracting uses reflection; prefer the Function overload for compile-time safety.

References

  • aj (opens in new window) - AssertJ docs (install, all sections linked above)
  • github.com/assertj/assertj - repository
  • github.com/assertj/assertj-examples - worked examples

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):

MatcherUse
value shouldBe expected / shouldNotBeEquality
value.shouldBeNull() / shouldNotBeNull()Null check
string.shouldContain("substring") / shouldStartWith / shouldEndWithString
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):

ModeBehavior
SingleInstanceOne spec instance for all tests (default; fastest)
InstancePerTestFresh spec instance per test (incl. nested contexts)
InstancePerLeafFresh 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-patternWhy it failsFix
Mix multiple spec styles in one projectReader confusionPick one
Default isolation + shared mutable stateTests interfereInstancePerTest
assertEquals(a, b) (JUnit style) in Kotest specsMixes paradigmsa 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" % Test

Test 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 tolerance

Async 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 coverageReport

Coverage via the sbt-scoverage plugin (Scala-native, not JaCoCo); cross-language projects need JaCoCo separately for the Java/Kotlin side.

Anti-patterns

Anti-patternWhy it failsFix
Mix specification styles in one projectReader confusionPick one
assert(x == y) instead of Matchers DSLLoses diff on failurex should equal(y)
Sync test bodies for async codeFuture never resolves; false passAsyncFlatSpec

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):

BlockPurpose
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-patternWhy it failsFix
Mockito alongside SpockTwo mocking APIs in one suiteSpock's built-in mocking
expect: for multi-step setupMixes given + when + thenExplicit given/when/then
_ * cardinality everywhereLoses interaction-count checkSpecify 1 * etc.
Spock for a Java-only projectGroovy adds classpath weightJUnit 5 (SKILL.md)
expect: + where: with no condition expression on the lineBare statements pass silentlyMake the line an expression Groovy evaluates as the assertion

Limitations

  • Requires the Groovy compiler in the build.
  • Groovy syntax learning curve for non-Groovy teams.
  • Spock 2 requires Java 8+ (Spock 1 is end-of-life).

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-patternWhy it failsFix
assertEquals(expected, actual) (JUnit order)TestNG order is reversed; misleading diffsassertEquals(actual, expected), or AssertJ (assertj.md (opens in new window))
Heavy dependsOnMethods chainsOrder coupling; cascade failuresIndependent tests + setup methods
Mixing TestNG + JUnit in one projectTwo runnersPick one

References