Using Spock Data Tables to Test Grails Controllers

Grails controllers often contain a deceptively large number of decisions. A request may be accepted for one HTTP method, rejected for another, redirected when a user is anonymous, or rendered as JSON when an API client sends a particular header. Testing every path with separate methods can make a controller specification repetitive and difficult to maintain. Spock data tables offer a compact way to describe these variations as readable examples.

A data-driven Grails test places changing inputs and expected results in a table, then runs the same specification logic for every row. This approach works well with URL mappings, command objects, validation errors, access rules and content negotiation. It also gives Java and Groovy developers a clear bridge from ordinary unit tests to expressive behaviour specifications.

Why data tables suit controller specifications

A controller test usually has a stable action and a changing set of circumstances. The action might always be save, while the request parameters, logged-in user, validation state and expected response differ. A Spock where: block separates that stable test behaviour from the values that vary.

Consider a controller that decides whether a customer can view an order:

def show(Long id) {
    Order order = Order.get(id)

    if (!order) {
        render status: 404
        return
    }

    if (order.customer.id != session.userId) {
        render status: 403
        return
    }

    [order: order]
}

The relevant cases are easy to express in a table: an existing order owned by the customer returns a model, a missing order returns 404, and an order belonging to another customer returns 403. The table becomes a small executable description of the controller contract rather than a collection of nearly identical test methods.

Spock data tables also improve failure messages. When a row fails, Spock reports the values used for that iteration, such as orderExists = false or expectedStatus = 404. That detail is especially useful in a Grails build running through Gradle or a continuous integration service, where the failure report may be the first place a developer sees the problem.

Build a focused Grails controller spec

A controller specification should test controller behaviour without turning into a full system test. In a Grails application, ControllerUnitTest provides the usual harness for calling an action, inspecting the model, checking the response status and verifying a redirect or rendered view.

A simple data-driven specification can look like this:

import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
import spock.lang.Unroll

@Mixin(ControllerUnitTest)
class OrderControllerSpec extends Specification {

    Class<?> getControllerClass() {
        OrderController
    }

    @Unroll
    def "show returns #expectedStatus for an order lookup"() {
        given:
        controller.session.userId = 10L

        when:
        def result = controller.show(orderId)

        then:
        response.status == expectedStatus
        result == expectedModel

        where:
        orderId | expectedStatus | expectedModel
        1L      | 200            | [order: _]
        999L    | 404            | null
    }
}

In practice, the first row requires a domain instance or a mocked static lookup, depending on the Grails version and testing strategy. The important pattern is the same: arrange each row’s state, call the action once, and verify the observable result. @Unroll makes the generated test name include the values from the current row.

For controller tests that use services, prefer a mock or stubbed service over a real database. This keeps the specification fast and ensures that a failure points to request handling rather than persistence configuration. A service response can also be supplied as a table value, allowing the same controller action to be tested against successful, empty and exceptional outcomes.

Model request variations clearly

Data tables are particularly effective for parameters and validation. A save action may receive a valid title, a blank title, an overlong title or an unexpected value. Instead of writing four methods with repeated request setup, place the input and expected response in separate columns.

@Unroll
def "save handles title '#title' with status #status"() {
    given:
    controller.params.title = title

    when:
    controller.save()

    then:
    response.status == status
    response.redirectedUrl == redirectUrl

    where:
    title                         | status | redirectUrl
    "Quarterly report"            | 302    | "/report/show/1"
    ""                            | 200    | null
    "A" * 201                     | 200    | null
}

The test can be expanded with columns for a flash message, rendered view or service invocation. Keep the table focused on meaningful boundaries rather than generating arbitrary input. A row for an empty string, a whitespace-only string and a value at the maximum length may be useful if the application treats those states differently. Ten random strings that all produce the same outcome add noise without increasing confidence.

When a row needs a more complex setup, use a data variable that describes the scenario rather than forcing every detail into a wide table. For example, a requestCase object can contain parameters and headers, while the expected status remains a separate column. This keeps the specification readable and prevents a table from becoming a second programming language that is harder to understand than the controller itself.

Australian applications often validate fields used in local workflows, such as an Australian Business Number, a postcode or a mobile number. A controller receiving a postcode can be tested with values such as 2000 for Sydney, 3000 for Melbourne and an invalid four-digit boundary. The test should verify the application’s actual rule rather than assume every postcode has the same business meaning across metropolitan and regional areas.

Exercise authentication and authorisation paths

Security checks are ideal candidates for parameterised controller tests because the request shape may be identical while the user context changes. A table can represent an anonymous visitor, an authenticated user with the correct role and an authenticated user who lacks permission.

@Unroll
def "admin action returns #expectedStatus for #userType"() {
    given:
    controller.session.user = user
    controller.params.id = 7L

    when:
    controller.delete()

    then:
    response.status == expectedStatus
    service.deleteCallCount == deleteCalls

    where:
    userType       | user              | expectedStatus | deleteCalls
    "guest"        | null              | 302            | 0
    "regular user" | [role: "USER"]    | 403            | 0
    "administrator"| [role: "ADMIN"]   | 204            | 1
}

The exact authentication mechanism varies between Grails applications. Spring Security Core may use springSecurityService, a mocked authentication object or request filters. The specification should set up that mechanism consistently, then use the data table to vary authorities, account status and ownership. Avoid testing only the happy path: an accidental authorisation regression is often more serious than an incorrect view name.

Security expectations also need to reflect Australian obligations. An application handling customer information may be subject to the Privacy Act 1988 and the Australian Privacy Principles, particularly when it stores identity, contact or financial data. A controller spec cannot prove full legislative compliance, but it can verify practical safeguards such as rejecting unauthenticated access, refusing cross-account records and avoiding sensitive details in rendered error responses.

For a local service operating in Sydney, Perth or Brisbane, the same access rule should apply regardless of the user’s location. Data tables can make that invariant visible by varying account roles and record ownership without duplicating the security assertions. Tests should also cover expired sessions and disabled accounts if those states are represented by the application.

Test JSON and content negotiation

Many Grails controllers serve browsers and API clients from the same action. The response may be an HTML view for a normal browser request and JSON for an Accept: application/json request. A table can capture this content negotiation without repeating the entire setup and verification sequence.

@Unroll
def "show responds with #contentType for #acceptHeader"() {
    given:
    request.addHeader("Accept", acceptHeader)
    controller.params.id = 1L
    service.find(_) >> [id: 1L, name: "Depot"]

    when:
    controller.show()

    then:
    response.contentType?.startsWith(contentType)
    response.status == 200

    where:
    acceptHeader           | contentType
    "text/html"             | "text/html"
    "application/json"     | "application/json"
}

For JSON assertions, parse the response body rather than comparing a long raw string. This avoids failures caused by property ordering or whitespace and lets the test focus on the contract:

def payload = new groovy.json.JsonSlurper().parseText(response.text)
assert payload.id == 1
assert payload.name == "Depot"

A controller that supports flexible query parameters may expose many combinations of filters, sorting and pagination. Before adding dozens of controller branches, consider whether the endpoint should have a clearer API boundary. A related GraphQL example shows how clients can request shaped data through GraphQL, which can reduce the number of specialised controller response variations.

Data tables can also test malformed JSON, absent headers, unsupported media types and empty result sets. For each row, assert the externally visible contract: status code, content type, selected fields and error structure. Avoid asserting implementation details such as which private helper method ran, since those checks make harmless refactoring unnecessarily difficult.

Keep tables maintainable in a growing project

A useful data table has descriptive columns and a small set of purposeful rows. Columns such as params, loggedIn, expectedStatus and expectedView explain the scenario. Columns named a, b and expected may be quick to write but force readers to inspect the setup before they understand the case.

Use helper methods for repeated construction:

private Map customer(Long id, String role = "CUSTOMER") {
    [id: id, role: role]
}

Then a table can contain customer(10L) and customer(20L) rather than repeating large maps. If a helper hides an important distinction, keep that value visible in the table. Readability is the main benefit of data-driven testing, so abstraction should support the scenario rather than conceal it.

A table is also a good place to encode boundary conditions, but it should not become an exhaustive replacement for property-based testing or integration tests. Controller unit specifications should verify routing decisions and response contracts. Separate integration tests can verify URL mappings, filters, database queries, transaction behaviour and real JSON rendering. The two levels reinforce each other when their responsibilities are clear.

On a team working across Australian time zones, fast tests make a practical difference. A developer in Adelaide or Canberra may run the suite before a review, while a remote build executes it later in the day. Stable, isolated controller specs reduce feedback time and help prevent a small request-handling change from delaying a release. Gradle test reports should show each unrolled scenario with enough context to reproduce it locally.

Be careful with table values that depend on dates, time zones or currency. Australian applications may operate across AEDT and AEST, and a controller that calculates due dates can behave differently near daylight-saving transitions. Supply a fixed clock or explicit zone in the test, and include rows for dates that cross a boundary. For financial data, use precise decimal values and test the formatting expected by the local market rather than relying on binary floating-point comparisons.

Connect controller tests to delivery confidence

A data-driven specification is most valuable when it reflects the behaviour that matters to users and downstream clients. Start with the controller’s public contract: accepted inputs, authentication requirements, status codes, redirects, views and response payloads. Turn each distinct outcome into a row, then add a boundary row when the business rule changes at a limit.

For an online marketplace serving customers in Melbourne and regional Victoria, those rows might cover a valid order, a missing order, a customer viewing someone else’s order, an expired session and an API request. For a government-facing workflow, they might include an invalid postcode, an absent consent field and a forbidden operation. These examples are more valuable than a large number of mechanically generated combinations because they document decisions that maintainers need to preserve.

The same discipline can be applied to probability-based or numeric rules used by a controller, such as eligibility thresholds or risk scores. A short explanation of probability basics can help developers who need to translate a business rule into test cases. The table should still use domain examples and explicit expected values, rather than relying on an opaque calculation inside the test itself.

Finally, run controller specifications as part of the normal Gradle test task and review them alongside production changes. When a new response branch is added, add a row that demonstrates it. When a rule is removed, delete the obsolete scenario. Over time, the table becomes a concise record of the endpoint’s supported behaviour, while Spock provides the executable checks that protect it.

Add a focused Spock data table to one Grails controller this week, beginning with its success path, an invalid request and an authorisation failure. Give each column a meaningful name, unroll the feature for useful failure messages and keep persistence concerns outside the unit specification. This small change can make controller behaviour clearer to read, quicker to verify and safer to evolve.