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 |
|---|---|---|
|
third-party; examples run with Groovy 6.0.0 |
|
Geb |
|
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')
Source: Spock documentation
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:(orsetup:) prepares the objects under test. -
when:performs the action, andthen:states the expected outcome. Every expression in athen:block is a condition and is checked like anassert(with the same diagram on failure, see below), so noassertkeyword 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 }); -
>> valuereturns a value,>>> [a, b]returns the values in turn, and>> { throw ... }computes the response; -
interactions declared in a
then:block are verified after thewhen:block, and a secondthen: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.
Source: Spock documentation: Extensions
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
@Unrollexplicitly; 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). -
GroovyTestCaseis a legacy JUnit 3 base class; prefer JUnit 5/6 withGroovyAssertor Spock. -
Geb’s coordinates changed from
org.gebishtoorg.apache.groovy.geb(8.0.x), and the Groovy testing guide’s Geb example (version 0.9.2) is stale.