Show Navigation

A Spock Test Tour for Grails 8

Working examples of every Spock test layer Grails 8 supports - DomainUnitTest, ServiceUnitTest + DataTest, ControllerUnitTest, @Integration + @Rollback, ContainerGebSpec functional tests, plus parameterised where: data tables.

Authors: James Fredley

Grails Version: 8

1 Getting Started

In this guide you will write working examples of every Spock test layer that Apache Grails 8 supports - from millisecond-fast DomainUnitTest constraint specs to full-stack @Integration specs that boot a real Spring context. The teaching framing is which test type catches which class of bug: constraint regressions belong in domain tests; query bugs in DataTest; controller routing in ControllerUnitTest; cross-layer wiring in @Integration tests; end-to-end UI flows only in functional tests.

This guide targets Apache Grails 8 / Spock 2.3 / JDK 21.

1.1 What You Will Build

By the end of the guide your sample app will have:

  • Book.groovy - a small domain class with title, isbn (regex-validated, unique), and pageCount (positive integer).

  • BookService.groovy - an @Service(Book) GORM data service with findByIsbn, countByPageCountGreaterThanEquals, and the standard CRUD methods.

  • BookController.groovy - a RestfulController<Book> so the JSON CRUD surface is testable from ControllerUnitTest.

  • Five spec files exercising the five test layers:

    • BookSpec - DomainUnitTest<Book>, constraint validation including a @Unroll parameterised where: table.

    • BookServiceSpec - ServiceUnitTest<BookService> + DataTest, query-shape testing against the in-memory datastore.

    • BookControllerSpec - ControllerUnitTest<BookController> + DataTest, action routing and response status testing.

    • BookIntegrationSpec - @Integration + @Rollback, full-stack tests with a real Spring context.

    • BookFunctionalSpec - @Integration + ContainerGebSpec, end-to-end browser tests driven by a Selenium container started via Testcontainers.

You will also touch the JVM-level concerns most teams get wrong: useJUnitPlatform() wiring, JaCoCo coverage report shape, and how ContainerGebSpec keeps functional tests fast and reproducible by removing host WebDriver binaries from the equation entirely.

1.2 What You Will Need

To complete this guide you will need:

  • JDK 21

  • About 30 minutes

1.3 How to Complete the Guide

You can either type the code in this guide as you read or skip ahead and clone the finished sample:

git clone -b grails8 https://github.com/grails-guides/grails-spock-test-tour.git
cd grails-spock-test-tour/complete
./gradlew test integrationTest

initial/ is a vanilla Grails 8 starter with the postgres, testcontainers, and mockito features. complete/ adds the Book domain, the BookService, the BookController, and the five spec files this guide walks through. The functional spec uses ContainerGebSpec from the standard org.apache.grails:grails-geb test fixtures, so no geb-with-webdriver-binaries feature is needed - just a running Docker daemon when you ./gradlew integrationTest.

2 Five Test Layers

Grails 8 supports five distinct test types. Each catches a different class of bug at a different speed:

Layer What it tests Speed When to reach for it

Domain unit test

Single domain class, constraints + transient logic

<100 ms

Constraint regressions; pure domain methods

Service / Data unit test

GORM finders, criteria, where queries against in-memory store

<500 ms

Query-shape bugs; service business logic

Controller unit test

Action routing, model preparation, response status

<500 ms

Routing regressions; @Validateable command-object binding

Integration test

Real Spring context, real datasource, transaction boundaries

5-20 s

Cross-layer wiring; security filter chains; bootstrap data

Functional test

Real booted app, real browser via Geb

10-60 s

End-to-end UI flows; JavaScript interactions

The discipline is put each bug in the cheapest test type that can catch it. A failing constraint should never reveal itself in a 30-second functional run. A controller routing typo should never need a real Postgres.

The next chapters walk through one canonical example per layer, sized so the file stays under 80 lines.

3 DomainUnitTest for Constraints

DomainUnitTest<Book> is the cheapest and fastest test layer in Grails. It loads in milliseconds because no Spring context boots; the in-memory datastore is per-spec via @AutoCleanup. Every constraint - blank, nullable, maxSize, unique, matches, min, max - is tested here.

src/test/groovy/example/BookSpec.groovy
package example

import grails.testing.gorm.DomainUnitTest
import spock.lang.Specification
import spock.lang.Unroll

/**
 * DomainUnitTest<Book> exercises just the constraint logic.
 *
 * Loads in milliseconds because no Spring context boots; the
 * @AutoCleanup datastore is in-memory and per-spec. Use this layer for
 * every constraint regression: blank, nullable, maxSize, unique,
 * matches, min, max, range.
 */
class BookSpec extends Specification implements DomainUnitTest<Book> {

    void "rejects a book with no title"() {
        when:
        Book b = new Book(isbn: '9780547928227', pageCount: 310)

        then:
        !b.validate()
        b.errors.fieldError('title').code == 'blank'
    }

    void "rejects a book with a malformed ISBN"() {
        when:
        Book b = new Book(title: 'Test', isbn: 'not-an-isbn', pageCount: 100)

        then:
        !b.validate()
        b.errors.fieldError('isbn').code == 'matches.invalid'
    }

    void "accepts a book with a valid ISBN-13"() {
        when:
        Book b = new Book(title: 'The Hobbit', isbn: '9780547928227', pageCount: 310)

        then:
        b.validate()
    }

    @Unroll
    void "pageCount #pageCount is #expectedValid"() {
        when:
        Book b = new Book(title: 'X', isbn: '9780547928227', pageCount: pageCount)

        then:
        b.validate() == expectedValid

        where:
        pageCount || expectedValid
        null      || true
        1         || true
        100       || true
        0         || false
        -1        || false
    }
}

Three patterns to point at:

  • b.errors.fieldError('title').code == 'blank' checks the constraint code, not the localised message. Codes are stable across releases and locales; messages are not.

  • @Unroll plus a where: data table collapses what would otherwise be five copy-pasted tests into one parameterised spec. The #pageCount token in the test name expands to the actual value, so the IDE’s run-tree shows each row separately.

  • No @Transactional, no Spring, no JDBC. The whole spec runs in a few hundred milliseconds across all six tests.

4 ServiceUnitTest + DataTest for Queries

ServiceUnitTest<BookService> plus DataTest exercises GORM finders against the in-memory datastore. Real GORM queries run; no Spring context, no real database.

src/test/groovy/example/BookServiceSpec.groovy
package example

import grails.testing.gorm.DataTest
import grails.testing.services.ServiceUnitTest
import spock.lang.Specification

/**
 * DataTest + ServiceUnitTest exercises the @Service(Book) interface
 * against the in-memory datastore. Real GORM finders run; no Spring
 * context, no real database. Best fit for testing query logic
 * (criteria, where queries, finder method names).
 */
class BookServiceSpec extends Specification
        implements ServiceUnitTest<BookService>, DataTest {

    Class[] getDomainClassesToMock() { [Book] }

    void setup() {
        new Book(title: 'Short Story',  isbn: '9780000000001', pageCount: 50 ).save(failOnError: true)
        new Book(title: 'Novella',      isbn: '9780000000002', pageCount: 150).save(failOnError: true)
        new Book(title: 'Novel',        isbn: '9780000000003', pageCount: 400).save(failOnError: true)
        new Book(title: 'Doorstopper',  isbn: '9780000000004', pageCount: 900).save(failOnError: true)
    }

    void "countByPageCountGreaterThanEquals returns the right count"() {
        expect:
        service.countByPageCountGreaterThanEquals(200) == 2
        service.countByPageCountGreaterThanEquals(0)   == 4
        service.countByPageCountGreaterThanEquals(901) == 0
    }

    void "findByIsbn returns the matching book"() {
        when:
        Book b = service.findByIsbn('9780000000003')

        then:
        b.title == 'Novel'
    }

    void "findByIsbn returns null when no match"() {
        expect:
        service.findByIsbn('9999999999999') == null
    }
}

Three things to highlight:

  • Class[] getDomainClassesToMock() { [Book] } is what DataTest reads to know which entities to register with the in-memory datastore. List every domain class your spec touches; forget one and you get a Hibernate could not find entity error at first use.

  • setup() runs before every feature method and seeds the datastore. There is no inter-spec state leak because DataTest clears the in-memory store between methods.

  • Dynamic-finder method names like countByPageCountGreaterThanEquals are interpreted by GORM at runtime. They are typo-prone; write a test the moment you add one.

5 ControllerUnitTest for Routing

ControllerUnitTest<BookController> exercises just the controller plus its routing, without booting Spring. Add DataTest so the auto-generated RestfulController actions can find persisted entities.

src/test/groovy/example/BookControllerSpec.groovy
package example

import grails.testing.gorm.DataTest
import grails.testing.web.controllers.ControllerUnitTest
import org.springframework.http.HttpStatus
import spock.lang.Specification

/**
 * ControllerUnitTest<BookController> wires up just enough of the web
 * layer to exercise action routing, model preparation, and response
 * status codes. Adding DataTest (with the right domain mocked) lets
 * the RestfulController's auto-generated CRUD actions actually find
 * persisted entities through GORM.
 */
class BookControllerSpec extends Specification
        implements ControllerUnitTest<BookController>, DataTest {

    Class[] getDomainClassesToMock() { [Book] }

    void "GET /books/{id} returns the book as JSON"() {
        given:
        Book b = new Book(title: 'The Hobbit', isbn: '9780547928227', pageCount: 310)
                  .save(failOnError: true, flush: true)

        when:
        request.method = 'GET'
        controller.show(b.id)

        then:
        response.status == HttpStatus.OK.value()
        response.json.title == 'The Hobbit'
    }

    void "GET /books/{id} for a missing id returns 404"() {
        when:
        request.method = 'GET'
        controller.show(99999L)

        then:
        response.status == HttpStatus.NOT_FOUND.value()
    }

    void "POST /books with a malformed ISBN returns 422"() {
        given:
        request.method = 'POST'
        request.json = [title: 'X', isbn: 'not-an-isbn']

        when:
        controller.save()

        then:
        response.status == HttpStatus.UNPROCESSABLE_ENTITY.value()
    }
}

Three things to point at:

  • request.method = 'GET' and request.json = […​] are the harness’s way of constructing a request without a real HTTP transport. The action runs synchronously; response carries the result.

  • response.status == HttpStatus.OK.value() (and HttpStatus.NOT_FOUND, HttpStatus.UNPROCESSABLE_ENTITY) - check the HTTP status enum, not magic numbers. Tests stay readable; status code constants change rarely but the enum names never do.

  • The malformed-ISBN test verifies the 422 surface contract from the REST Library guide's validation chapter. The same input always returns a structured 422 with the right errors[].code.

6 @Integration With @Rollback

@Integration boots the full Spring context against a real datasource (whatever the test environment in application.yml configures - H2 by default). @Rollback rolls back every test method’s database changes so specs stay isolated.

src/integration-test/groovy/example/BookIntegrationSpec.groovy
package example

import grails.gorm.transactions.Rollback
import grails.testing.mixin.integration.Integration
import spock.lang.Specification

/**
 * @Integration boots the full Spring context against a real datasource
 * (whatever the `test` environment in application.yml configures - H2
 * by default). @Rollback ensures every test method's database changes
 * are rolled back at the end so specs stay isolated.
 *
 * Use this layer for cross-layer concerns: domain + service + transaction
 * boundary together. Avoid it for what unit tests can answer faster.
 */
@Integration
@Rollback
class BookIntegrationSpec extends Specification {

    BookService bookService

    void "a saved book round-trips through GORM"() {
        given:
        Book draft = new Book(title: 'Round Trip', isbn: '9780547928227', pageCount: 200)

        when:
        Book saved = bookService.save(draft)

        then:
        saved.id != null

        when:
        Book reloaded = bookService.get(saved.id)

        then:
        reloaded.title == 'Round Trip'
        reloaded.isbn  == '9780547928227'
    }

    void "saving a book with a duplicate isbn fails validation"() {
        given:
        bookService.save(new Book(title: 'First',  isbn: '9780547928227', pageCount: 100))

        when:
        Book second = bookService.save(new Book(title: 'Second', isbn: '9780547928227', pageCount: 200))

        then:
        second == null || second.hasErrors()
    }
}

Three things to highlight:

  • BookService bookService is a real Spring-injected bean here, not a unit-test mock. The full GORM stack runs; service.save() does a real INSERT, service.get() does a real SELECT.

  • @Rollback is non-negotiable. Without it, the second test sees the first test’s data and the third test sees both. Specs become order-dependent and impossible to debug.

  • The duplicate-ISBN test deliberately catches the constraint failure inside the second save call rather than letting it throw. unique: true constraint failures surface as errors.hasErrors(), not as exceptions, in GORM 7+.

Integration specs run an order of magnitude slower than unit tests (5-20 seconds per spec). Use them only for what unit tests genuinely cannot answer: cross-layer wiring, security filter chains, bootstrap-data correctness.

7 ContainerGebSpec Functional Tests

Functional tests use Geb 8 to drive a real browser against a real booted app. In Grails 8 the canonical (and only fully supported) base class is grails.plugin.geb.ContainerGebSpec from testFixtures("org.apache.grails:grails-geb"), already on the test classpath in the standard web profile. It starts a Selenium-Chrome browser inside a Testcontainers container instead of relying on local WebDriver binaries, so the host’s only requirement is a running Docker daemon.

src/integration-test/groovy/example/BookFunctionalSpec.groovy
package example

import grails.plugin.geb.ContainerGebSpec
import grails.testing.mixin.integration.Integration

/**
 * Functional test driven by Geb 8 against a real booted application.
 *
 * ContainerGebSpec (from `testFixtures("org.apache.grails:grails-geb")`)
 * starts a Selenium-Chrome container via Testcontainers and points the
 * browser at the host-side Grails app. No local WebDriver binaries are
 * installed; the only host requirement is a running Docker daemon.
 *
 * @Integration is mandatory: GrailsContainerGebExtension throws at
 * runtime if the annotation is missing. @Rollback is NOT used here -
 * functional tests exercise the full HTTP stack and need committed
 * data.
 */
@Integration
class BookFunctionalSpec extends ContainerGebSpec {

    void "the book index page renders the seeded books"() {
        when:
        go '/books'

        then:
        title.contains('Book')
        $('table tbody tr').size() > 0
    }
}

Three patterns to highlight:

  • @Integration is mandatory for ContainerGebSpec. The Spock extension validates the annotation at runtime and throws IllegalArgumentException: ContainerGebSpec classes must be annotated with @Integration. if it is missing.

  • @Rollback is not used here. Functional tests exercise the full HTTP stack against committed data, so transactional rollback would hide what the user actually sees.

  • The geb-with-webdriver-binaries forge feature from earlier Grails majors is obsolete. ContainerGebSpec brings its own browser via Testcontainers; adding the legacy feature wires up a second Chrome driver no test ever uses.

Three keep-it-fast disciplines:

  • Run unit and integration tests first, functional last. Failure is then localised: if every functional test fails it is because the app would not boot, which is an integration-test problem.

  • Reuse a single browser session across feature methods in the same spec - Geb does this by default. Re-launching Chrome between every method would add real overhead.

  • Let Testcontainers reuse the Selenium image across runs. The first cold start pulls the image (~200 MB); subsequent runs reuse it.

Functional specs are an order of magnitude slower than integration specs (10-60 s per spec) because the full HTTP stack and a real browser are involved. Use them only for what integration tests genuinely cannot answer: end-to-end user flows, JavaScript-driven interactions, multi-page form journeys.

The matching CI/CD guide models this as a four-stage job graph (validation → unit → integration → functional) so a broken unit test does not waste 60 seconds on functional runs. The CI runner needs Docker available too; GitHub-hosted Linux runners ship with Docker out of the box.

8 Parameterised where: Data Tables

Spock’s where: data tables turn N copy-pasted tests into one parameterised feature method. Reach for them whenever a test follows the shape "for input X, expect output Y", with N values of X.

The BookSpec already contains one:

@Unroll
void "pageCount #pageCount is #expectedValid"() {
    when:
    Book b = new Book(title: 'X', isbn: '9780547928227', pageCount: pageCount)

    then:
    b.validate() == expectedValid

    where:
    pageCount || expectedValid
    null      || true
    1         || true
    100       || true
    0         || false
    -1        || false
}

Two patterns worth pointing at:

  • @Unroll makes the IDE’s run-tree show every row as a separate test. Without it, the five rows show up as one combined test that either passes or fails.

  • || separates inputs from expected outputs. Spock allows | everywhere, but the convention inputs || expected_outputs is widely-followed and reads naturally.

The other two Spock features that pay back disproportionately:

  • Block discipline: given: / when: / then: / expect:. A spec that uses expect: for one-liners and given/when/then for everything else is dramatically more readable than one that uses def setup() and assertions scattered everywhere.

  • Mock interaction count: 1 * service.save(_) asserts the mock was called exactly once with any argument. 0 * _ asserts no further interactions. These are far more precise than verifying state after the fact.

9 useJUnitPlatform and JaCoCo Coverage

Spock 2.x runs on the JUnit Platform. Grails 8’s forge-generated build.gradle already does the right thing:

tasks.withType(Test).configureEach {
    useJUnitPlatform()
}

That single line tells Gradle to invoke the JUnit 5 runner instead of the legacy JUnit3 / 4 runner. Without it, Spock 2 specs would not be discovered and only legacy JUnit 4 specs would run.

Coverage with JaCoCo is one block:

plugins {
    id 'jacoco'
}

jacocoTestReport {
    dependsOn test, integrationTest
    executionData fileTree(buildDir).include('jacoco/*.exec')
    reports {
        xml.required = true       // for Codecov upload
        html.required = true      // for human inspection
    }
}

The dependsOn test, integrationTest line is what makes the report cover both layers; without it, only test data is included and integration-only branches show as untested. The xml.required = true produces the jacoco.xml that Codecov reads from build/reports/jacoco/test/jacocoTestReport.xml.

10 Do you need help with Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.