Testing with Spock & Geb

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). The Spock examples were run for real with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14) against org.spockframework:spock-core:2.5-groovy-6.0, and the output shown is the output of those runs. The Geb examples were not run: they need a browser and a WebDriver, and no Geb release is documented as supporting Groovy 6, so they are labelled not run here and are shown only to explain the API.

Neither library is part of Groovy. Spock (spockframework.org) and Geb (groovy.apache.org/geb) are third-party projects with their own release cycles, and the Groovy testing guide covers them with one short section each, which is the reason they share a page here. For the testing facilities that ship with Groovy itself (power assertions, GroovyAssert, MockFor, JUnit support) see Testing with JUnit & Groovy Test and Assertions & Power Assert.

Which versions support which Groovy line

Spock encodes the Groovy line it was built for in its version: 2.5-groovy-6.0 is Spock 2.5 for Groovy 6.0. On Maven Central the same Spock release is published for several lines (checked 2026-10-14: 2.5-groovy-6.0, 2.5-groovy-5.0, 2.5-groovy-4.0 and 2.5-groovy-3.0), so the suffix has to match the Groovy on your classpath. Spock 2 is built on the JUnit Platform, so a build tool runs it like any other JUnit 5 style test engine.

Geb moved into the Apache Groovy project. The current release is 8.0.1 (2025-11-17) under the group org.apache.groovy.geb, with modules such as geb-core, geb-spock, geb-junit5 and geb-testng. Before that it was published as org.gebish:geb-core, whose last release was 7.0 (2023). The page Functional Tests with Geb in the official Groovy testing guide still shows the old org.gebish coordinates with version 0.9.2 and a Selenium 2.x driver, so do not copy dependencies from it. The published POMs of Geb 8.0.1 reference Groovy 4.0.26 and Spock 2.3-groovy-4.0, and this page makes no claim that Geb 8.0.1 works with Groovy 6.

Library Coordinates used here Status on this page

org.spockframework:spock-core:2.5-groovy-6.0

third-party; examples run with Groovy 6.0.0

Geb

org.apache.groovy.geb:geb-core:8.0.1 and geb-spock:8.0.1

third-party; examples not run here; Groovy 6 support not claimed

Adding Spock to a build

With Gradle, add the dependency and switch the test task to the JUnit Platform:

// build.gradle
dependencies {
    testImplementation 'org.apache.groovy:groovy:6.0.0'
    testImplementation 'org.spockframework:spock-core:2.5-groovy-6.0'
}

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

With Maven, keep the specifications under src/test/groovy and compile them with the GMavenPlus plugin (see Build Tools: Gradle, Maven & Ant), then run them with Surefire, which discovers the Spock engine on the JUnit Platform.

For the examples below no build is needed. A script can @Grab Spock and hand its specification classes to the JUnit Platform launcher; the small helper below does that and prints one line per test. It is the Runner class used by every example on this page (save it next to the scripts as Runner.groovy).

// Runner.groovy -- helper for running specifications from a script
import org.junit.platform.launcher.*
import org.junit.platform.launcher.core.*
import org.junit.platform.launcher.listeners.*
import org.junit.platform.engine.discovery.DiscoverySelectors
import org.junit.platform.engine.TestExecutionResult

class Runner {
    static void run(Class... specs) {
        def req = LauncherDiscoveryRequestBuilder.request()
                .selectors(specs.collect { DiscoverySelectors.selectClass(it) }).build()
        def summary = new SummaryGeneratingListener()
        def printer = new TestExecutionListener() {
            void executionFinished(TestIdentifier id, TestExecutionResult r) {
                if (id.isTest()) {
                    println "${r.status.toString().padRight(10)} ${id.displayName}"
                    r.throwable.ifPresent { println "    " + it.message.readLines().take(8).join('\n    ') }
                }
            }
        }
        LauncherFactory.create().execute(req, summary, printer)
        def s = summary.summary
        println "tests found=${s.testsFoundCount} succeeded=${s.testsSucceededCount} failed=${s.testsFailedCount}"
    }
}

Each script starts with the same three lines, then import spock.lang.*, and runs with groovy -cp . script.groovy:

@Grab('org.spockframework:spock-core:2.5-groovy-6.0')
@Grab('org.junit.platform:junit-platform-launcher:1.13.4')
@GrabExclude('org.apache.groovy:groovy')

A first specification: blocks

A Spock test is a specification: a class that extends spock.lang.Specification, whose feature methods are named with a string. The body of a feature method is divided by labelled blocks:

  • given: (or setup:) prepares the objects under test.

  • when: performs the action, and then: states the expected outcome. Every expression in a then: block is a condition and is checked like an assert (with the same diagram on failure, see below), so no assert keyword is needed.

  • expect: combines a stimulus and a response in one block, for simple functional checks.

  • where: supplies data for a data-driven feature.

  • cleanup: runs afterwards whether or not the feature failed.

Strings after a label (given: "a tomato that wants ...") are documentation for readers and for failure reports. thrown(Type) in a then: block asserts that the when: block threw that exception and returns it for further checks.

class Plant {
    String name
    int minMoisture
    boolean needsWater(int moisture) { moisture < minMoisture }
}

class PlantSpec extends Specification {
    def "a dry plant needs water"() {
        given: "a tomato that wants at least 40 percent soil moisture"
        def tomato = new Plant(name: 'tomato', minMoisture: 40)

        when: "the soil reads 25 percent"
        def result = tomato.needsWater(25)

        then:
        result
    }

    def "needsWater compares with the threshold"() {
        expect:
        new Plant(minMoisture: limit).needsWater(reading) == thirsty

        where:
        limit | reading || thirsty
        40    | 25      || true
        40    | 40      || false
        40    | 80      || false
        15    | 14      || true
    }

    def "a negative threshold is rejected"() {
        when:
        throw new IllegalArgumentException("minMoisture must be >= 0")

        then:
        def e = thrown(IllegalArgumentException)
        e.message.contains('>= 0')
    }
}

Runner.run(PlantSpec)
SUCCESSFUL a dry plant needs water
SUCCESSFUL needsWater compares with the threshold [limit: 40, reading: 25, thirsty: true, #0]
SUCCESSFUL needsWater compares with the threshold [limit: 40, reading: 40, thirsty: false, #1]
SUCCESSFUL needsWater compares with the threshold [limit: 40, reading: 80, thirsty: false, #2]
SUCCESSFUL needsWater compares with the threshold [limit: 15, reading: 14, thirsty: true, #3]
SUCCESSFUL needsWater compares with the threshold
SUCCESSFUL a negative threshold is rejected
tests found=7 succeeded=7 failed=0

The data-driven feature ran four times, once per row of the where: table, and Spock reported each iteration with its data. The unlabelled line is the parent feature node. In the table, | separates columns and the double bar || is only a visual divider between inputs and expected outputs.

Failure reports and data-driven names

A failed condition prints the same kind of diagram as a power assert (see Assertions & Power Assert), including the value of each sub-expression, and for a string comparison a similarity summary. @Unroll is the default in Spock 2, so a data-driven feature is reported once per row; placeholders such as #plant in the method name are replaced with the row’s values.

@groovy.transform.ToString(includeNames = true)
class Bench { int moisture; List<String> labels = ['tomatoes', 'basil'] }

class FailingSpec extends Specification {
    def "shows the failed condition"() {
        given:
        def bench = new Bench(moisture: 31)

        expect:
        bench.moisture > 40
        bench.labels.size() == 3
    }

    @Unroll
    def "#plant at #moisture percent -> #verdict"() {
        expect:
        (moisture < 40 ? 'water' : 'wait') == verdict

        where:
        plant      | moisture || verdict
        'tomatoes' | 25       || 'water'
        'basil'    | 70       || 'wait'
        'mint'     | 20       || 'wait'
    }
}
FAILED     shows the failed condition
    Condition not satisfied:

    bench.moisture > 40
    |     |        |
    |     31       false
    Bench(moisture:31, labels:[tomatoes, basil])
SUCCESSFUL tomatoes at 25 percent -> water
SUCCESSFUL basil at 70 percent -> wait
FAILED     mint at 20 percent -> wait
    Condition not satisfied:

    (moisture < 40 ? 'water' : 'wait') == verdict
     |        |                        |  |
     20       true                     |  wait
                                       false
                                       3 differences (40% similarity)
                                       wa(-)t(er)
SUCCESSFUL #plant at #moisture percent -> #verdict
tests found=5 succeeded=3 failed=2

The first feature stops at its first failing condition (bench.moisture > 40), so the second condition is never reached. The deliberately wrong row for mint is reported under its own name, which is what makes @Unroll names worth writing carefully. Besides tables, where: accepts data pipes (litres << [10, 25, 100]) and derived variables, as the fixtures example below shows.

Mocks, stubs and interactions

Spock has mocking built in. Mock() creates a collaborator whose calls you verify; Stub() creates one that only supplies canned answers and is never verified; Spy() wraps a real object. Interactions are declared with the shape cardinality * target.method(arguments) >> response:

  • the cardinality is a number or a range: 1 * valve.open(10), (1..3) * valve.close(), 0 * valve._;

  • _ is a wildcard for one argument, 0 * _ means "no other interaction on any mock", and a closure constrains an argument: valve.open({ it > 5 });

  • >> value returns a value, >>> [a, b] returns the values in turn, and >> { throw ... } computes the response;

  • interactions declared in a then: block are verified after the when: block, and a second then: block requires that its interactions happen after the ones in the first, which gives ordering.

The greenhouse waterer below depends on a valve and a sensor bus. The bus is a stub (only answers), the valve is a mock (verified).

interface Valve { void open(int seconds); void close(); boolean isOpen() }
interface SensorBus { int read(String zone) throws IOException }

class Waterer {
    Valve valve; SensorBus bus
    boolean waterIfDry(String zone, int limit) {
        int m = bus.read(zone)
        if (m >= limit) return false
        valve.open(10)
        valve.close()
        true
    }
}

class WatererSpec extends Specification {
    Valve valve = Mock()
    SensorBus bus = Stub()
    @Subject Waterer waterer = new Waterer(valve: valve, bus: bus)

    def "opens the valve for 10 seconds when the soil is dry"() {
        given:
        bus.read('bench-1') >> 22

        when:
        def watered = waterer.waterIfDry('bench-1', 40)

        then:
        watered
        1 * valve.open(10)
        1 * valve.close()
        0 * _
    }

    def "leaves the valve alone when the soil is wet"() {
        given:
        bus.read(_) >> 75

        when:
        waterer.waterIfDry('bed-2', 40)

        then:
        0 * valve._
    }

    def "open comes before close"() {
        given:
        bus.read(_) >>> [10, 90]

        when:
        waterer.waterIfDry('a', 40)
        waterer.waterIfDry('a', 40)

        then:
        1 * valve.open({ it > 5 })

        then:
        1 * valve.close()
    }

    def "a failing sensor surfaces to the caller"() {
        given:
        bus.read('broken') >> { throw new IOException('bus offline') }

        when:
        waterer.waterIfDry('broken', 40)

        then:
        thrown(IOException)
        0 * valve.open(_)
    }
}

class TooFewSpec extends Specification {
    Valve valve = Mock()
    def "interaction mismatch report"() {
        when:
        valve.close()

        then:
        2 * valve.close()
    }
}

Runner.run(WatererSpec, TooFewSpec)
SUCCESSFUL opens the valve for 10 seconds when the soil is dry
SUCCESSFUL leaves the valve alone when the soil is wet
SUCCESSFUL open comes before close
SUCCESSFUL a failing sensor surfaces to the caller
FAILED     interaction mismatch report
    Too few invocations for:

    2 * valve.close()   (1 invocation)

    Unmatched invocations (ordered by similarity):

    None

tests found=5 succeeded=3 failed=2

The count line reports two failures because the parent node of the failing feature is also counted. TooFewSpec is deliberately wrong to show the report for an interaction that happened once instead of twice.

One detail worth knowing: the SensorBus.read method declares throws IOException. Spock builds the stub as a dynamic proxy, and a checked exception that the interface does not declare is wrapped by the JDK in an UndeclaredThrowableException; the first run of this example, without the throws clause, failed with exactly that message instead of the expected IOException.

Fixtures, helpers and extensions

Fixture methods have fixed names: setup() and cleanup() run around every feature (and every iteration of a data-driven one), setupSpec() and cleanupSpec() run once per specification. State shared by all features is declared with @Shared; ordinary fields are re-created for each feature. Inside a then: block, old(expression) evaluates the expression before the when: block, with(obj) { ... } applies conditions to an object, and verifyAll(obj) { ... } reports every failing condition instead of stopping at the first.

Extensions are annotations: @Ignore, @IgnoreIf/@Requires (conditions on the environment), @Timeout, @Retry, @Stepwise (ordered features that stop at the first failure), @Rollup (report all iterations of a data-driven feature as one test) and @PendingFeature (a feature that is expected to fail and is reported as aborted until it passes).

class Tank {
    int level
    void drain(int litres) { level -= litres }
}

class FixtureSpec extends Specification {
    @Shared int specInstances = 0     // one value for the whole specification
    Tank tank                         // fresh for every feature method

    def setupSpec() { println 'setupSpec: once' }
    def setup()     { tank = new Tank(level: 100); specInstances++; println 'setup' }
    def cleanup()   { println 'cleanup' }
    def cleanupSpec() { println "cleanupSpec: ${specInstances} features ran" }

    def "old() compares with the value before the when block"() {
        when:
        tank.drain(30)

        then:
        tank.level == old(tank.level) - 30
    }

    def "with() and verifyAll() group conditions"() {
        expect:
        with(tank) { level == 100 }
        verifyAll(tank) {
            level > 0
            level <= 100
        }
    }

    @Rollup
    def "data tables with a computed column"() {
        expect:
        tank.level - litres == left

        where:
        litres << [10, 25, 100]
        left = 100 - litres
    }

    @Ignore("sensor rig not wired up yet")
    def "skipped feature"() { expect: false }

    @IgnoreIf({ System.getProperty('os.name').contains('Mac') })
    def "skipped on macOS only"() { expect: true }

    @Timeout(1)
    def "finishes within a second"() { expect: true }

    @PendingFeature
    def "not implemented yet, a failure is expected"() { expect: tank.level == 0 }
}

Runner.run(FixtureSpec)
setupSpec: once
setup
cleanup
SUCCESSFUL old() compares with the value before the when block
setup
cleanup
SUCCESSFUL with() and verifyAll() group conditions
setup
cleanup
setup
cleanup
setup
cleanup
SUCCESSFUL data tables with a computed column
setup
cleanup
SUCCESSFUL finishes within a second
setup
cleanup
ABORTED    not implemented yet, a failure is expected
    Feature not yet implemented correctly.
cleanupSpec: 7 features ran
tests found=7 succeeded=4 failed=0

setup/cleanup ran seven times: once for each of the five executed features and three times for the rolled-up data-driven one (which counts as one reported test). The `@Ignore`d feature and the macOS-only one (this run was on macOS) are skipped, which the launcher reports separately, so the helper above does not print them.

Geb: browser automation (not run here)

Geb is a Groovy layer over Selenium WebDriver for functional tests and scraping. Its core ideas are a Browser that pairs a driver with a current page, a jQuery-like $() function for locating elements, the page object pattern (classes that describe a page’s URL, check that you are on it, and expose its content), reusable modules for repeated components, and implicit waiting through waitFor. Geb integrates with Spock through the geb-spock module, whose base class is geb.spock.GebSpec, with JUnit through geb-junit5, and with TestNG.

The examples assume a greenhouse dashboard served at http://localhost:8080/ (a local development server, for instance the one built on the page HTTP Client with HttpBuilder). They have not been run, no browser was driven, and they make no statement about which Groovy line they work on.

Dependencies for a Gradle test build (coordinates of the Geb 8.0.1 release; Geb’s own POM references Selenium 4.27.0, so a matching driver is needed):

// build.gradle -- not run here
dependencies {
    testImplementation 'org.spockframework:spock-core:2.5-groovy-6.0'   // choose the suffix for your Groovy line
    testImplementation 'org.apache.groovy.geb:geb-spock:8.0.1'
    testImplementation 'org.seleniumhq.selenium:selenium-chrome-driver:4.27.0'
}

A script that does not need a test framework uses Browser.drive:

// not run here
@Grab('org.apache.groovy.geb:geb-core:8.0.1')
@Grab('org.seleniumhq.selenium:selenium-chrome-driver:4.27.0')
import geb.Browser
import org.openqa.selenium.chrome.ChromeDriver

Browser.drive(driver: new ChromeDriver(), baseUrl: 'http://localhost:8080/') {
    go '/zones'
    assert title == 'Zones'
    println $('li.zone').collect { it.text() }
}

In a test the driver and base URL come from a GebConfig.groovy on the test classpath, and the specification extends GebSpec. Page objects keep selectors out of the tests:

// GebConfig.groovy -- not run here
import org.openqa.selenium.chrome.ChromeDriver

driver = { new ChromeDriver() }
baseUrl = 'http://localhost:8080/'
// not run here
import geb.Module
import geb.Page
import geb.spock.GebSpec

class ZoneRow extends Module {
    static content = {
        name { $('.name').text() }
        moisture { $('.moisture').text() as int }
        waterButton { $('button.water') }
    }
}

class ZonesPage extends Page {
    static url = '/zones'
    static at = { title == 'Zones' }
    static content = {
        rows { moduleList ZoneRow, $('tr.zone') }
        status(wait: true) { $('#status').text() }
    }
}

class ZonesSpec extends GebSpec {
    def "watering a dry zone updates the status"() {
        when:
        to ZonesPage
        rows.find { it.name == 'bench-1' }.waterButton.click()

        then:
        at ZonesPage
        status == 'Watering bench-1'
    }
}

to navigates to the page’s url and sets it as the current page, at verifies the at closure (and fails the feature if it does not hold), moduleList turns a set of elements into a list of modules, and wait: true on a content definition makes access wait for the element, which is how Geb handles pages that change asynchronously. For the wider choice of browser-automation tools see also the other browser-testing pages of this site (Selenium, Playwright and Cypress).

Source: Geb manual

Where the book is outdated

The book Groovy in Action (first edition) predates both libraries: it covers JUnit 3 and GroovyTestCase, and neither Spock nor Geb is part of it. Use these current references instead of the book:

  • Spock 1.x ran on JUnit 4 and used @Unroll explicitly; Spock 2 runs on the JUnit Platform, reports data-driven iterations as unrolled by default, and ships a release per Groovy line (2.5-groovy-6.0).

  • GroovyTestCase is a legacy JUnit 3 base class; prefer JUnit 5/6 with GroovyAssert or Spock.

  • Geb’s coordinates changed from org.gebish to org.apache.groovy.geb (8.0.x), and the Groovy testing guide’s Geb example (version 0.9.2) is stale.