Testing with JUnit & groovy-test

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every runnable example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). Groovy has no test framework of its own: tests are ordinary JUnit tests written in Groovy, and the language adds three things on top of that, namely the power assert (see Assertions & Power Assert), concise mocking built from maps, closures and metaclasses, and a runner that executes a test file straight from the groovy command. The Java side of the story (JUnit lifecycle, Mockito, build-tool integration) is on Testing in Java and applies unchanged; this page covers what is different or extra in Groovy. Specification-style testing is on Testing with Spock & Geb.

The pieces

Artifact What it gives you When to use it

org.junit.jupiter:junit-jupiter (JUnit 5 or 6)

Annotations, assertions, the engine. Plain Groovy classes with @Test methods work with no Groovy-specific module.

Every new Groovy project.

org.apache.groovy:groovy-test

groovy.test.GroovyAssert (shouldFail, assertScript), @NotYetImplemented, GroovyTestCase, groovy.mock.interceptor.

Whenever a test needs shouldFail or MockFor.

org.apache.groovy:groovy-test-junit5

Runs a JUnit 5 test class as a script from the groovy command or GroovyConsole.

You are on JUnit 5.

org.apache.groovy:groovy-test-junit6 (new in 6.0)

The same script runner for JUnit 6, plus @ExpectedToFail, @ForkedJvm, @GroovyEnabledIf and @GroovyDisabledIf.

You are on JUnit 6. It brings in compatible JUnit 6 libraries as one dependency.

JUnit 4 (junit:junit:4.13.2)

@Test, @Before, org.junit.Assert; groovy.test.GroovyAssert extends it.

Existing JUnit 4 suites.

The Groovy 6.0.0 binary distribution bundles groovy-test, groovy-test-junit5, groovy-test-junit6, JUnit 4.13.2 and JUnit Jupiter 6.1.3 in its lib directory, which is why the single-file examples below run with nothing to download except junit-jupiter-params where parameterized tests are used.

A JUnit 6 test as a script

A test class does not need a build tool to run. The groovy command detects a JUnit test class in the script and hands it to the matching runner (JUnit6Runner for Jupiter 6, JUnit5Runner for Jupiter 5, a JUnit 4 and a JUnit 3 runner, and a TestNG one). The runner executes the tests and prints a one-line summary. The class under test goes after the test class in the file, because the runner looks at the first class in the script.

@Grab("org.junit.jupiter:junit-jupiter-params:6.1.3")
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.RepeatedTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import static groovy.test.GroovyAssert.shouldFail

class ThermostatTests {
    Thermostat thermostat

    @BeforeEach
    void setUp() { thermostat = new Thermostat() }

    @Test
    void coolsWhenHot() {
        assert thermostat.decide(26.5) == 'COOL'
    }

    @Test
    void rejectsMissingReading() {
        def e = shouldFail(IllegalArgumentException) { thermostat.decide(null) }
        assert e.message == 'no reading'
    }

    @ParameterizedTest
    @ValueSource(doubles = [21.5d, 22.0d, 22.9d])
    void idleInsideTheBand(double reading) {
        assert thermostat.decide(reading as BigDecimal) == 'IDLE'
    }

    @RepeatedTest(2)
    void heatsWhenCold() {
        assert thermostat.decide(15) == 'HEAT'
    }
}

class Thermostat {
    BigDecimal target = 22.0
    String decide(BigDecimal reading) {
        if (reading == null) throw new IllegalArgumentException('no reading')
        reading > target + 1 ? 'COOL' : reading < target - 1 ? 'HEAT' : 'IDLE'
    }
}
JUnit6 launcher: passed=7, aborted=0, failed=0, skipped=0, time=248ms

Seven tests ran: one for coolsWhenHot, one for rejectsMissingReading, three parameter values and two repetitions. Nothing in the test mentions a Groovy-specific assertion method: the bare assert is the assertion, and shouldFail is the one helper from groovy-test. The @Grab line only fetches the junit-jupiter-params jar that the bundled JUnit does not include; in a Gradle or Maven project it is an ordinary dependency instead.

What a failing run looks like

When an assert inside a test fails, the runner reports the test, prints the power-assert diagram with the value of every sub-expression, and the groovy process exits with status 1, so a shell script or CI step sees the failure.

import org.junit.jupiter.api.Test

class FailingTests {
    @Test
    void tooCold() {
        def readings = [18, 19]
        def minimum = 20
        assert readings.min() >= minimum
    }

    @Test
    void fine() {
        assert 1 + 1 == 2
    }
}
JUnit6 launcher: passed=1, aborted=0, failed=1, skipped=0, time=65ms

Failures (1):
  JUnit Jupiter:FailingTests:tooCold()
    MethodSource [className = 'FailingTests', methodName = 'tooCold', methodParameterTypes = '']
    => Assertion failed:

assert readings.min() >= minimum
       |        |     |  |
       [18, 19] 18    |  20
                      false

       org.codehaus.groovy.runtime.InvokerHelper.createAssertError(InvokerHelper.java:409)
       FailingTests.tooCold(failing.groovy:8)

(After the summary the groovy command also logs a GroovyRuntimeException stack trace; the exit status is 1.) The diagram is the same power-assert output described on Assertions & Power Assert; a JUnit assertEquals would have reported only two values, which is why the Groovy documentation recommends assert over the inherited JUnit assertion methods.

JUnit 5 and JUnit 6

JUnit 6 keeps the Jupiter API packages (org.junit.jupiter.api, org.junit.jupiter.params), so a JUnit 5 test class in Groovy compiles and runs against JUnit 6 without source changes; what changes is the version of the dependency and, in the script case, the runner module. The Groovy 6.0 release notes state that most users need no Groovy module for JUnit 6: add the standard Jupiter dependencies to Gradle or Maven and write the tests as usual. The Groovy guide’s JUnit 5 example shows the Groovy-flavoured features of a Jupiter test class (a closure as the lazy message supplier, @RepeatedTest, an @ParameterizedTest with a Groovy list as the annotation array, and @TestFactory returning a list):

// from the official guide: fragment, needs the imports of the Jupiter API
class MyTest {
  @Test
  void streamSum() {
    assertTrue(Stream.of(1, 2, 3)
      .mapToInt(i -> i)
      .sum() > 5, () -> "Sum should be greater than 5")
  }

  @RepeatedTest(value=2, name = "{displayName} {currentRepetition}/{totalRepetitions}")
  void streamSumRepeated() {
    assert Stream.of(1, 2, 3).mapToInt(i -> i).sum() == 6
  }

  private boolean isPalindrome(s) { s == s.reverse()  }

  @ParameterizedTest
  @ValueSource(strings = [ "racecar", "radar", "able was I ere I saw elba" ])
  void palindromes(String candidate) {
    assert isPalindrome(candidate)
  }

  @TestFactory
  def dynamicTestCollection() {[
    dynamicTest("Add test") { -> assert 1 + 1 == 2 },
    dynamicTest("Multiply Test", () -> { assert 2 * 3 == 6 })
  ]}
}

The guide quotes a summary of the form JUnit5 launcher: passed=8, failed=0, skipped=0, time=246ms for the script runner; more detail is available at the FINE level of the LoggingListener logger. The fragment above is quoted from the guide and was not run here as a whole; the next example uses the same Jupiter features and was run.

Parameterized and dynamic tests combine well with the GDK method combinations (see below), which builds the cross product of several lists:

@Grab("org.junit.jupiter:junit-jupiter-params:6.1.3")
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

import static org.junit.jupiter.api.DynamicTest.dynamicTest

class BandTests {
    static String decide(BigDecimal reading, BigDecimal target = 22.0) {
        reading > target + 1 ? 'COOL' : reading < target - 1 ? 'HEAT' : 'IDLE'
    }

    @ParameterizedTest(name = '{0} degrees -> {1}')
    @CsvSource(['15,HEAT', '22,IDLE', '30,COOL'])
    void decides(BigDecimal reading, String expected) {
        assert decide(reading) == expected
    }

    @TestFactory
    Collection<DynamicTest> everyTargetAndReading() {
        [[18, 22, 26], [20, 22]].combinations().collect { reading, target ->
            dynamicTest("reading $reading target $target") { ->
                assert decide(reading, target) in ['HEAT', 'IDLE', 'COOL']
            }
        }
    }
}
JUnit6 launcher: passed=9, aborted=0, failed=0, skipped=0, time=73ms

Three CSV rows plus six generated combinations give nine tests.

JUnit 4 and GroovyAssert

groovy.test.GroovyAssert extends org.junit.Assert and adds static helpers that work in any JUnit version, as long as they are imported statically. shouldFail runs a closure and passes only if it throws; with a class argument it also checks the type (subclasses are accepted); it returns the exception itself, so the message and cause can be asserted. When the closure does not throw, shouldFail fails with an error saying the closure should have failed.

import org.junit.Test
import org.junit.Before
import static groovy.test.GroovyAssert.shouldFail

class RegistryTests {
    List<String> zones

    @Before
    void setUp() { zones = ['north', 'south'] }

    @Test
    void indexOutOfBounds() {
        def e = shouldFail(IndexOutOfBoundsException) { zones.get(5) }
        assert e.message.contains('5')
    }

    @Test
    void returnsTheExceptionItself() {
        def e = shouldFail {
            throw new IllegalStateException('valve stuck', new IOException('timeout'))
        }
        assert e instanceof IllegalStateException
        assert e.cause.message == 'timeout'
    }

    @Test
    void shouldFailFailsWhenNothingThrown() {
        def e = shouldFail(AssertionError) {
            shouldFail { 'no exception here' }
        }
        println e.message
    }

    @Test
    void assertScriptRuns() {
        groovy.test.GroovyAssert.assertScript '''
            def a = 1
            assert a + 1 == 2
        '''
    }
}
Closure RegistryTests$_shouldFailFailsWhenNothingThrown_closure3$_closure4@72be135f should have failed
JUnit 4 Runner, Tests: 4, Failures: 0, Time: 78

assertScript evaluates a string of Groovy code and fails the test if it throws, which is handy for testing script snippets and DSLs. The same shouldFail and assertScript work in JUnit 5 and 6 classes, since they are static methods. The shouldFail helper is also summarised on Assertions & Power Assert.

Marking tests that are expected to fail

Two annotations turn a red test green on purpose, for different reasons.

@NotYetImplemented (package groovy.test) is for a test written ahead of the feature. While the test fails, the run counts it as passing; the moment the feature works, the test fails with a message telling you to remove the annotation, so the marker cannot be forgotten. It replaces the older notYetImplemented() call of GroovyTestCase.

import groovy.test.NotYetImplemented
import org.junit.jupiter.api.Test

class PendingTests {
    @NotYetImplemented
    @Test
    void frostForecast() {
        assert forecastLow(3) == -2     // feature not written yet
    }

    static forecastLow(days) { 0 }

    @Test
    void regular() { assert true }
}
JUnit6 launcher: passed=2, aborted=0, failed=0, skipped=0, time=34ms

Changing forecastLow to return -2 makes the same file report the unexpected success:

Failures (1):
  JUnit Jupiter:PendingTests:frostForecast()
    MethodSource [className = 'PendingTests', methodName = 'frostForecast', methodParameterTypes = '']
    => java.lang.AssertionError: Method is marked with @NotYetImplemented but passes unexpectedly

@ExpectedToFail (package groovy.junit6.plugin, from groovy-test-junit6) is the more general, JUnit 6 only variant: the test passes when it throws, optionally only for a given exception type or message, and fails when it does not. Use it to document a known bug with a reason. The module’s conditional annotations @GroovyEnabledIf and @GroovyDisabledIf take a closure whose delegate exposes javaVersion, systemProperties, systemEnvironment, junitTags, junitDisplayName and junitUniqueId, so a condition is Groovy code rather than a fixed property name:

import groovy.junit6.plugin.ExpectedToFail
import groovy.junit6.plugin.GroovyDisabledIf
import groovy.junit6.plugin.GroovyEnabledIf
import org.junit.jupiter.api.Test

class ConditionTests {
    @Test
    @ExpectedToFail(exception = ArithmeticException, reason = 'division by zero is not guarded yet')
    void knownBug() {
        1 / 0
    }

    @Test
    @GroovyEnabledIf(value = { javaVersion >= 17 }, reason = 'needs JDK 17+')
    void runsOnModernJdk() {
        assert true
    }

    @Test
    @GroovyDisabledIf(value = { javaVersion >= 17 }, reason = 'legacy JDKs only')
    void skippedOnModernJdk() {
        assert false
    }
}
JUnit6 launcher: passed=2, aborted=0, failed=0, skipped=1, time=42ms

If the body of knownBug is changed so that nothing throws, the test fails with @ExpectedToFail: test was expected to fail but passed (division by zero is not guarded yet), which is how the annotation tells you the bug was fixed. The annotation attribute for the closure is value, so it has to be written value = { ... } when reason is also given.

Running a test in a fresh JVM with @ForkedJvm

@ForkedJvm (also groovy-test-junit6) runs the annotated method, or every method of an annotated class, in a newly started JVM. It is meant for tests that need a clean process: a system property that must not leak, a JVM option such as --add-opens, or a small heap. Its attributes are systemProperties, jvmArgs, inheritProperties (exact names or prefix patterns ending in a star, to carry build-tool properties into the child) and excludeFromClasspath. It composes with @ExpectedToFail in either order.

import groovy.junit6.plugin.ForkedJvm
import org.junit.jupiter.api.Test

class ForkTest {
    @Test
    @ForkedJvm(systemProperties = ['greenhouse.zone=north'], jvmArgs = ['-Xmx64m'])
    void runsInAFreshJvm() {
        assert System.getProperty('greenhouse.zone') == 'north'
        println "forked JVM max heap: ${Runtime.runtime.maxMemory().intdiv(1024 * 1024)} MB"
    }
}
forked JVM max heap: 64 MB
[         1 tests successful      ]
[         0 tests failed          ]

This run used a compiled class: the file was compiled with groovyc and launched through the JUnit Platform Launcher with the distribution’s lib directory on the classpath. Run as a plain script from the groovy command, the same test failed in this environment because the forked JVM could not load groovy.junit6.plugin.ForkedJvmTestRunner from the script class loader, so use @ForkedJvm from a build tool or from compiled classes rather than from a one-file script.

GroovyTestCase, the legacy base class

groovy.test.GroovyTestCase (JUnit 3 style: extend a base class, name methods test...) predates annotations. It adds shouldFail (which returns the exception message, not the exception), assertScript, assertLength, assertContains and notYetImplemented(). New code should use JUnit 5 or 6 with GroovyAssert; GroovyTestCase stays for existing suites. It is in groovy-test and must be imported, since it is not a default import.

import groovy.test.GroovyTestCase

class LegacyTests extends GroovyTestCase {
    void testShouldFailReturnsMessage() {
        def msg = shouldFail(ArithmeticException) { 1 / 0 }
        assert msg == 'Division by zero'
    }

    void testAssertScript() {
        assertScript '''
            def x = 1
            assert x + 1 == 2
        '''
    }

    void testClassicAssertions() {
        assertEquals 'north', 'north'
        assertNotNull 'x'
        assertLength 3, [1, 2, 3] as int[]
        assertContains 2, [1, 2, 3] as int[]
    }
}
...
Time: 0,144

OK (3 tests)

The difference between the two shouldFail methods is the one trap when migrating: GroovyTestCase.shouldFail returns the message string, GroovyAssert.shouldFail returns the exception. Code such as shouldFail(...).contains('x') has to become shouldFail(...).message.contains('x').

Mocking and stubbing with the language

Groovy makes hand-written test doubles cheap enough that many tests need no mocking library. These techniques need only the language itself, and they are the dynamic counterpart of the Mockito style shown on Testing in Java. Spock offers a fuller mocking DSL (Testing with Spock & Geb).

Map and closure coercion

A map whose keys are method names and whose values are closures can be coerced to an interface or class with as. A closure can be coerced to any type with a single abstract method; where the target type is known (a typed variable or a typed parameter) the as can be left out (implicit SAM coercion).

interface SensorClient {
    BigDecimal read(String zone)
}

class ZoneReport {
    SensorClient client
    String describe(String zone) {
        def t = client.read(zone)
        "$zone: ${t >= 30 ? 'HOT' : 'OK'} ($t)"
    }
}

// 1. map coercion: keys are method names, values are closures
def hot = [read: { String zone -> 31.5 }] as SensorClient
assert new ZoneReport(client: hot).describe('north') == 'north: HOT (31.5)'

// a map can also record the calls it receives
def calls = []
def spy = [read: { String zone -> calls << zone; 20.0 }] as SensorClient
new ZoneReport(client: spy).describe('south')
new ZoneReport(client: spy).describe('east')
assert calls == ['south', 'east']

// 2. closure coercion for single-method types
def cold = { String zone -> 12.0 } as SensorClient
assert new ZoneReport(client: cold).describe('north') == 'north: OK (12.0)'

// implicit SAM coercion: no "as" needed when the target type is known
SensorClient fixed = { zone -> 25.0 }
assert fixed.read('any') == 25.0
assert !(fixed instanceof Closure)

// 3. an Expando as a hand-written fake
def fake = new Expando(read: { String z -> 19.0 })
assert new ZoneReport(client: fake as SensorClient).describe('west') == 'west: OK (19.0)'

// 4. what map coercion does when a method is missing
def partial = [other: { 1 }] as SensorClient
try {
    partial.read('north')
} catch (UnsupportedOperationException e) {
    println "caught ${e.class.simpleName}"
}
println 'mock examples passed'
caught UnsupportedOperationException
mock examples passed

Map and closure coercion create a proxy, so the object is not a direct instance of the target class, and a metaclass change on the proxy does not affect the original type. A call to a method missing from the map throws UnsupportedOperationException. Map coercion is also not meant for custom java.util.Map subclasses as the target type, because the coercion mechanism targets specific collection classes.

MockFor and StubFor

groovy.mock.interceptor.MockFor and StubFor replace a collaborator class for the duration of a use closure, using the metaclass system. demand records what the collaborator should do; with MockFor the calls must occur in the declared order and the declared number of times, and use verifies this when its closure ends. StubFor is the loose variant: order is ignored and verify is optional.

import groovy.mock.interceptor.MockFor
import groovy.mock.interceptor.StubFor

class Sensor {
    String zone
    BigDecimal reading
}

class Greenhouse {
    Sensor north
    String summary() { "$north.zone=$north.reading" }
}

// MockFor: strict order and count; use{} verifies when the closure ends
def mock = new MockFor(Sensor)
mock.demand.getZone { 'north' }
mock.demand.getReading { 21.5 }
mock.use {
    def g = new Greenhouse(north: new Sensor())
    assert g.summary() == 'north=21.5'
}
println 'strict mock satisfied'

// calling in the wrong order fails
def strict = new MockFor(Sensor)
strict.demand.getZone { 'north' }
strict.demand.getReading { 21.5 }
try {
    strict.use {
        def s = new Sensor()
        s.reading                 // reading requested before zone
    }
} catch (Throwable e) {
    println "${e.class.name}: ${e.message}"
}

// a demanded call that never happens is reported by verify
def unused = new MockFor(Sensor)
unused.demand.getZone { 'north' }
try {
    unused.use { /* nothing called */ }
} catch (Throwable e) {
    println "${e.class.name}: ${e.message}"
}

// StubFor: order does not matter and verification is optional
def stub = new StubFor(Sensor)
stub.demand.with {
    getReading { 18.0 }
    getZone { 'south' }
}
stub.use {
    def s = new Sensor()
    assert s.zone == 'south'
    assert s.reading == 18.0
}
stub.expect.verify()
println 'stub satisfied'
strict mock satisfied
junit.framework.AssertionFailedError: No call to 'getReading' expected at this point. Still 1 call(s) to 'getZone' expected.
junit.framework.AssertionFailedError: verify[0]: expected 1..1 call(s) to 'getZone' but was called 0 time(s).
stub satisfied

These failures are junit.framework.AssertionFailedError, which groovy-test still raises, so catch Throwable (or let the test framework report it) rather than AssertionError in a framework-agnostic helper. MockFor and StubFor work through the metaclass, so they cannot intercept calls compiled statically. The following run shows it: the same Sensor mock is seen by a dynamic caller but ignored by a @CompileStatic caller, and verify then complains that the demanded call never happened.

import groovy.mock.interceptor.MockFor
import groovy.transform.CompileStatic

class Sensor {
    String zone
}

class DynamicCaller {
    String zoneOf(Sensor s) { s.zone }
}

@CompileStatic
class StaticCaller {
    String zoneOf(Sensor s) { s.zone }
}

def dyn = new MockFor(Sensor)
dyn.demand.getZone { 'mocked' }
dyn.use {
    println "dynamic caller sees: ${new DynamicCaller().zoneOf(new Sensor(zone: 'real'))}"
}

def stat = new MockFor(Sensor)
stat.demand.getZone { 'mocked' }
try {
    stat.use {
        println "static caller sees: ${new StaticCaller().zoneOf(new Sensor(zone: 'real'))}"
    }
} catch (Throwable e) {
    println "${e.class.simpleName}: ${e.message}"
}
dynamic caller sees: mocked
static caller sees: real
AssertionFailedError: verify[0]: expected 1..1 call(s) to 'getZone' but was called 0 time(s).

For statically compiled code, and for Java classes, use a mocking library (Spock or Mockito) or a hand-written fake behind an interface; see Static Type Checking & @CompileStatic for why static dispatch bypasses the metaclass.

ExpandoMetaClass mocking

Every class has a metaClass property, and assigning to it replaces methods, static methods and constructors at runtime. That makes ExpandoMetaClass (EMC) a way to fake a static factory or a constructor, which most Java mocking libraries cannot do without bytecode tricks. The price is global state: a class-level change stays for the rest of the JVM unless it is removed, so a test must undo it. Per-instance changes affect only that object.

class Reading {
    String zone
    BigDecimal value
    static Reading fromSensor(String zone) { throw new IllegalStateException('needs hardware') }
}

// static method replaced for one test
Reading.metaClass.static.fromSensor = { String zone -> new Reading(zone: zone, value: 20.0) }
assert Reading.fromSensor('north').value == 20.0

// constructor replaced
Reading.metaClass.constructor << { String zone -> new Reading(zone: zone, value: 0.0) }
assert new Reading('south').zone == 'south'

// per-instance: only this object changes
def r = new Reading(zone: 'east', value: 5.0)
r.metaClass.getValue = { -> 99.0 }
assert r.value == 99.0
assert new Reading(zone: 'east', value: 5.0).value == 5.0

// the class-level change is global until it is removed
GroovySystem.metaClassRegistry.removeMetaClass(Reading)
try {
    Reading.fromSensor('north')
} catch (IllegalStateException e) {
    println "after removeMetaClass: ${e.message}"
}
after removeMetaClass: needs hardware

In a test class, undo class-level changes in the cleanup method (@AfterEach, @After) with GroovySystem.metaClassRegistry.removeMetaClass(Type); otherwise a faked fromSensor leaks into every later test in the same JVM. The metaclass system is described on Metaclasses, ExpandoMetaClass & Categories.

Generating test data with combinations

The GDK adds combinations() and eachCombination(closure) to Iterable. Given a list of lists, combinations returns every tuple that picks one element from each list; eachCombination calls a closure for each tuple, spreading it over the closure parameters. They produce exhaustive argument grids for a method under test without nested loops.

def combos = [[2, 3], [4, 5, 6]].combinations()
assert combos == [[2, 4], [3, 4], [2, 5], [3, 5], [2, 6], [3, 6]]

def seen = []
[['north', 'south'], [15, 30]].eachCombination { zone, temp -> seen << "$zone@$temp" }
println seen

// all argument combinations for a method under test
def label = { String zone, int t, boolean heating -> "$zone/$t/${heating ? 'H' : '-'}" }
def all = [['north', 'south'], [10, 25], [true, false]].combinations().collect { label(*it) }
assert all.size() == 8
println all
[north@15, south@15, north@30, south@30]
[north/10/H, south/10/H, north/25/H, south/25/H, north/10/-, south/10/-, north/25/-, south/25/-]

The first list varies fastest. The @TestFactory example earlier turns each tuple into a named test.

Java fixtures with JavaShell

org.apache.groovy.util.JavaShell compiles Java source held in a string, in memory. In a test it removes the need for a src/test/java file that exists only to support one Groovy test: declare the Java class inside the test, then give a GroovyShell the shell’s class loader so the Groovy code can see it.

import org.apache.groovy.util.JavaShell

def js = new JavaShell()
js.compile('fixtures.PojoFixture', """
    package fixtures;
    public class PojoFixture {
        private final String name;
        public PojoFixture(String name) { this.name = name; }
        public String getName() { return name; }
    }
""")
def shell = new GroovyShell(js.classLoader)
shell.evaluate """
    def bean = fixtures.PojoFixture.newInstance('Groovy')
    assert bean.name == 'Groovy'
"""
println 'fixture ok'
fixture ok

compileAllTo writes the generated .class files to a directory, which allows comparing javac output with Groovy’s own bytecode for joint-compilation or annotation-processing tests. JavaShell is stable in 6.0.

Build-tool setup

Outside single-file scripts, tests run through Gradle or Maven with the JUnit Platform. The fragments below are illustrative and were not run here; version numbers are examples to adjust. The Groovy 6.0 release notes show org.junit.jupiter:junit-jupiter:6.0.3 for JUnit 6.

// build.gradle (Groovy DSL), fragment
plugins { id 'groovy' }

repositories { mavenCentral() }

dependencies {
    implementation 'org.apache.groovy:groovy:6.0.0'
    testImplementation 'org.apache.groovy:groovy-test:6.0.0'
    testImplementation 'org.junit.jupiter:junit-jupiter:6.0.3'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') { useJUnitPlatform() }

Groovy test sources live in src/test/groovy and are compiled by the same Groovy plugin as the main code. With Maven the equivalent needs a Groovy compiler plugin (such as GMavenPlus) and the Surefire plugin on the JUnit Platform. Build-tool integration for Java is covered on Build & Tooling.

Code coverage

Coverage tools instrument bytecode, so any JVM coverage tool works on Groovy classes. The official guide still shows the Cobertura Gradle plugin with the removed compile and testCompile configurations; treat that snippet as historical. JaCoCo is the usual choice today: enable the jacoco plugin in Gradle (or the Maven plugin) and run the tests. Groovy generates many synthetic members (call-site caches, metaclass accessors, closure classes), and JaCoCo ignores some of them and not others, so treat branch percentages on dynamically compiled Groovy as approximate and check the JaCoCo report for the class in question; @CompileStatic code produces bytecode much closer to Java’s and reports more predictably. Coverage was not run here.

Quick reference

Need Use

Run a test file without a build tool

groovy MyTests.groovy with a JUnit 4, 5 or 6 test class as the first class in the file

Assert a condition

the bare assert (power assert), not assertEquals

Expect an exception, keep it

shouldFail(Type) { ... } from GroovyAssert (returns the exception)

Run a Groovy snippet as a test

assertScript '...'

Test ahead of the feature

@NotYetImplemented

Document a known failing test (JUnit 6)

@ExpectedToFail(exception = X, reason = '...')

Condition on JDK or environment (JUnit 6)

@GroovyEnabledIf(value = { ... }) / @GroovyDisabledIf

Clean JVM for one test (JUnit 6)

@ForkedJvm from a build tool or compiled classes

Fake an interface

[method: { ... }] as Type or { ... } as Type

Fake a collaborator class, strict order

MockFor / StubFor (dynamic Groovy only)

Fake a static method or constructor

Type.metaClass.static.m = { ... }, then removeMetaClass

Test data grid

[[...], [...]].combinations()

Legacy suites

groovy.test.GroovyTestCase

Where the book is outdated

  • The book builds its tests on GroovyTestCase (JUnit 3); in current Groovy that class is legacy, and JUnit 5 or 6 with GroovyAssert (or Spock) is the recommended style.

  • JUnit 6 and the groovy-test-junit6 module, @ExpectedToFail, @ForkedJvm and the Groovy-script conditions are 6.0 features unknown to the book.

  • @NotYetImplemented replaces the notYetImplemented() method call.

  • GroovyTestCase.shouldFail returns the message while GroovyAssert.shouldFail returns the exception; older examples may assume the former.

  • The groovy.mock.interceptor classes behave as the book describes but cannot intercept statically compiled code, which matters more now that @CompileStatic is common.