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> exposing a JSON CRUD surface (testable from ControllerUnitTest) whose index action also renders an HTML table at /books for the functional test to drive.

  • 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: 'title is omitted from the map constructor, so it is null'
        Book b = new Book(isbn: '9780547928227', pageCount: 310)

        then: 'the nullable constraint - not blank - is what fires for a null value'
        !b.validate()
        b.errors.getFieldError('title').code == 'nullable'
    }

    void "rejects a blank title"() {
        when: 'a blank string is assigned directly (the map constructor would convert it to null)'
        Book b = new Book(isbn: '9780547928227', pageCount: 310)
        b.title = ''

        then: 'now the blank constraint fires'
        !b.validate()
        b.errors.getFieldError('title').code == 'blank'
    }

    void "rejects a title longer than maxSize"() {
        when:
        Book b = new Book(title: 'x' * 256, isbn: '9780547928227', pageCount: 310)

        then:
        !b.validate()
        b.errors.getFieldError('title').code == 'maxSize.exceeded'
    }

    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.getFieldError('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.getFieldError('title').code checks the constraint code, not the localised message. Codes are stable across releases and locales; messages are not. Note the subtlety the spec demonstrates: omitting title from the map constructor leaves it null, so the nullable code fires - not blank. To exercise the blank constraint you must assign b.title = '' directly, because Grails data binding converts an empty string passed through the map constructor into null.

  • @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() {
        // flush: true forces the in-memory datastore to persist before the
        // GORM finders below query it - without it the finders see zero rows.
        new Book(title: 'Short Story',  isbn: '9780000000001', pageCount: 50 ).save(flush: true, failOnError: true)
        new Book(title: 'Novella',      isbn: '9780000000002', pageCount: 150).save(flush: true, failOnError: true)
        new Book(title: 'Novel',        isbn: '9780000000003', pageCount: 400).save(flush: true, failOnError: true)
        new Book(title: 'Doorstopper',  isbn: '9780000000004', pageCount: 900).save(flush: true, 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
    }

    void "get returns the book when it exists"() {
        given: 'the isbn from the setup fixture'
        Book b = Book.findByIsbn('9780000000001')

        expect:
        service.get(b.id)?.title == 'Short Story'
    }

    void "get returns null when the id does not exist"() {
        expect:
        service.get(99999L) == null
    }

    void "save persists a book and returns it with an id"() {
        when:
        Book saved = service.save(new Book(
            title: 'New Book', isbn: '9780000000005', pageCount: 200))

        then:
        saved.id != null
        saved.title == 'New Book'

        and: 'it is visible to a subsequent query'
        service.countByPageCountGreaterThanEquals(0) == 5
    }

    void "save rejects a duplicate isbn with ValidationException"() {
        given:
        service.save(new Book(title: 'Original', isbn: '9780000000005', pageCount: 100))
        Book.withSession { it.flush() }

        when:
        service.save(new Book(title: 'Duplicate', isbn: '9780000000005', pageCount: 200))

        then:
        def ex = thrown(grails.validation.ValidationException)
        ex.message.contains('unique')
    }

    void "list returns all books when called without constraints"() {
        expect:
        service.list([:]).size() == 4
    }

    void "list respects the max parameter"() {
        expect:
        service.list([max: 2]).size() == 2
    }
}

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: 'RestfulController.show() reads params.id - it takes no argument'
        request.method = 'GET'
        params.id = b.id
        controller.show()

        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'
        params.id = 99999L
        controller.show()

        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()
    }

    void "index returns the persisted books in its model"() {
        given:
        new Book(title: 'First', isbn: '9780547928227', pageCount: 100)
            .save(flush: true, failOnError: true)
        new Book(title: 'Second', isbn: '9780547928228', pageCount: 200)
            .save(flush: true, failOnError: true)

        when: 'the index action builds its model (HTML format returns the model map)'
        request.method = 'GET'
        Map model = controller.index(10)

        then:
        model.bookCount == 2
        model.bookList*.title.containsAll(['First', 'Second'])
    }

    void "POST /books with valid data saves and returns 201"() {
        given:
        request.method = 'POST'
        request.json = [title: 'Valid Book', isbn: '9780547928228', pageCount: 150]

        when:
        controller.save()

        then:
        response.status == HttpStatus.CREATED.value()
        response.json.title == 'Valid Book'
        response.json.isbn  == '9780547928228'
    }

    void "GET /books/create returns a new transient instance"() {
        when:
        request.method = 'GET'
        request.format = 'json'
        controller.create()

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

    void "GET /books/{id}/edit returns the book for editing"() {
        given:
        Book b = new Book(title: 'Editable', isbn: '9780547928228', pageCount: 100)
            .save(flush: true, failOnError: true)

        when:
        request.method = 'GET'
        request.format = 'json'
        params.id = b.id
        controller.edit()

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

    void "PUT /books/{id} updates the book"() {
        given:
        Book b = new Book(title: 'Original', isbn: '9780547928228', pageCount: 100)
            .save(flush: true, failOnError: true)

        when:
        request.method = 'PUT'
        request.json = [title: 'Updated']
        params.id = b.id
        controller.update()

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

    void "PUT /books/{id} with invalid data returns 422"() {
        given:
        Book b = new Book(title: 'Original', isbn: '9780547928229', pageCount: 100)
            .save(flush: true, failOnError: true)

        when:
        request.method = 'PUT'
        request.json = [title: '']
        params.id = b.id
        controller.update()

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

    void "DELETE /books/{id} deletes the book"() {
        given:
        Book b = new Book(title: 'Deletable', isbn: '9780547928228', pageCount: 100)
            .save(flush: true, failOnError: true)

        when:
        request.method = 'DELETE'
        params.id = b.id
        controller.delete()

        then:
        response.status == HttpStatus.NO_CONTENT.value()
        Book.get(b.id) == null
    }
}

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 - the test environment here points at a Testcontainers-managed PostgreSQL database (jdbc:tc:postgresql:…​ in application-test.yml), so the specs run against the same engine as production. @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 grails.validation.ValidationException
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 is rejected"() {
        given: 'a persisted book, flushed so the unique validator can see it'
        bookService.save(new Book(title: 'First',  isbn: '9780547928227', pageCount: 100))
        Book.withSession { it.flush() }

        when: 'a second book reuses the isbn'
        bookService.save(new Book(title: 'Second', isbn: '9780547928227', pageCount: 200))

        then: 'the @Service save throws a ValidationException (failOnError is implicit) carrying the unique error'
        ValidationException ex = thrown()
        ex.message.contains('unique')
    }
}

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 first flushes the persisted book (Book.withSession { it.flush() }) so the unique validator can see it - without the flush the second insert is still pending and validation passes. It then expects the second save to throw: a GORM @Service save applies failOnError semantics, so a unique: true violation surfaces as a thrown grails.validation.ValidationException. (Calling domain.save() directly, without failOnError: true, would instead return null with errors.hasErrors().)

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. The fixture is therefore seeded inside its own
 * withNewTransaction block; a bare GORM call would fail because an
 * @Integration spec without @Rollback has no ambient Hibernate session.
 */
@Integration
class BookFunctionalSpec extends ContainerGebSpec {

    void setup() {
        Book.withNewTransaction {
            if (!Book.findByIsbn('9780261103344')) {
                new Book(title: 'The Hobbit', isbn: '9780261103344', pageCount: 310).save(failOnError: true)
            }
        }
    }

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

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

}

The page the test drives

go '/books' needs a real HTML page with a table. BookController stays a RestfulController for the JSON CRUD surface, but its index action is overridden so the HTML format renders a GSP while JSON clients keep their collection response:

grails-app/controllers/example/BookController.groovy
package example

import grails.rest.RestfulController

class BookController extends RestfulController<Book> {
    // 'json' keeps the REST CRUD surface; 'html' lets the index action render
    // grails-app/views/book/index.gsp so a browser (and the Geb functional
    // test) gets a real HTML table at /books.
    static responseFormats = ['json', 'html']
    BookService bookService
    BookController() { super(Book) }

    // Minimal override so the show action is explicitly registered as a
    // controller action (sibling project debugging revealed that inherited
    // RestfulController actions can be invisible to the URL-mapping layer);
    // it delegates to RestfulController.show() to stay framework-aligned.
    def show() {
        super.show()
    }

    // Override index so the HTML format renders the GSP table view (through the
    // sitemesh layout) while JSON clients still get the REST collection.
    def index(Integer max) {
        params.max = Math.min(max ?: 100, 100)
        List<Book> books = listAllResources(params)
        withFormat {
            html { [bookList: books, bookCount: countResources()] }
            json { respond books, [model: [bookCount: countResources()]] }
        }
    }

}

A resources URL mapping exposes the controller at /books:

grails-app/controllers/example/UrlMappings.groovy
package example

class UrlMappings {
    static mappings = {
        "/books"(resources: 'book')
        "/$namespace/$controller/$action?/$id?(.$format)?" {}
        "/$controller/$action?/$id?(.$format)?"{
            constraints {
                // apply constraints here
            }
        }

        "/"(view:"/index")
        "500"(view:'/error')
        "404"(view:'/notFound')

    }
}

and a minimal GSP renders the table the spec asserts against:

grails-app/views/book/index.gsp
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Book List</title>
</head>
<body>
<h1>Book List</h1>
<table>
    <thead>
        <tr><th>Title</th><th>ISBN</th><th>Pages</th></tr>
    </thead>
    <tbody>
        <g:each in="${bookList}" var="book">
            <tr>
                <td>${book.title}</td>
                <td>${book.isbn}</td>
                <td>${book.pageCount}</td>
            </tr>
        </g:each>
    </tbody>
</table>
</body>
</html>

Because the spec is @Integration without @Rollback, it seeds its fixture inside a withNewTransaction block so the row is committed and visible to the browser. A bare GORM call in an un-rolled-back @Integration spec has no ambient Hibernate session and would throw. BootStrap seed data is not a substitute here: it does not populate the test database in the integration context, so the spec owns its own fixture.

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(layout.buildDirectory.get().asFile).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.