Spock Data-Driven Testing: @Unroll, @Where, and Parameterized Specs

Spock Data-Driven Testing: @Unroll, @Where, and Parameterized Specs

Spock's data-driven testing features turn a single spec into a parameterized test suite with almost no boilerplate. The where block, data tables, and @Unroll annotation work together to produce clear, readable test output for every data combination.

The Where Block

Add a where block to any feature method and Spock runs it once per row:

def "maximum of two numbers"() {
    expect:
    Math.max(a, b) == expected

    where:
    a  | b  | expected
    1  | 3  | 3
    7  | 4  | 7
    0  | 0  | 0
    -1 | 1  | 1
}

Spock generates four independent test runs. If one fails, the others still execute.

Unrolling Test Names

By default, Spock names parameterized tests maximum of two numbers[0], [1], etc. With @Unroll, each iteration gets its own descriptive name using variable interpolation:

@Unroll
def "max(#a, #b) == #expected"() {
    expect:
    Math.max(a, b) == expected

    where:
    a  | b  | expected
    1  | 3  | 3
    7  | 4  | 7
    0  | 0  | 0
}

Output in test reports:

✓ max(1, 3) == 3
✓ max(7, 4) == 7
✓ max(0, 0) == 0

In Spock 2.0+, @Unroll is the default behavior — you don't need the annotation unless overriding class-level settings.

The Double Pipe Separator

The || separator is cosmetic — it visually separates inputs from outputs:

where:
input         || expected
"hello"       || "HELLO"
"world"       || "WORLD"
""            || ""
"MixedCase"   || "MIXEDCASE"

Spock treats | and || identically; the convention is to use || before expected values.

Data Pipes

For programmatic data, use the << (left shift) operator:

def "all positive numbers are valid"() {
    expect:
    validator.isPositive(n)

    where:
    n << [1, 5, 100, Integer.MAX_VALUE]
}

Combine multiple pipes:

def "cartesian product of two lists"() {
    expect:
    a + b > 0

    where:
    a << [1, 2, 3]
    b << [10, 20, 30]
}

Multiple pipes iterate in parallel (not Cartesian), so both lists must have the same length.

Derived Variables

Variables in where can reference earlier ones in the same block:

where:
base  | exponent | expected
2     | 3        | base ** exponent  // 8
3     | 2        | base ** exponent  // 9
10    | 0        | base ** exponent  // 1

Derived variables make the expected column self-documenting.

Data Tables vs Data Pipes

Use a data table when you have fixed, readable input/output pairs. Use pipes when data comes from code — lists, generators, or external sources:

// Table: clear, readable
where:
email                    | valid
"user@example.com"       | true
"invalid-email"          | false
""                       | false
"user@"                  | false

// Pipe: programmatic
where:
input << EmailFixtures.validEmails()

Loading Data from External Sources

@Unroll
def "validate user #name"() {
    expect:
    service.validate(user)

    where:
    [name, user] << loadUsersFromCsv("src/test/resources/users.csv")
}

private List loadUsersFromCsv(String path) {
    new File(path).readLines().drop(1).collect { line ->
        def parts = line.split(",")
        [parts[0], new User(name: parts[0], email: parts[1])]
    }
}

Or from a database fixture:

where:
user << UserFixtures.allValidUsers()

Combining Where with Given/When/Then

The where block works with all block styles:

@Unroll
def "creating order with #quantity items calculates correct total"() {
    given:
    def order = new Order()

    when:
    quantity.times { order.addItem(new Item(price: itemPrice)) }

    then:
    order.total == expectedTotal

    where:
    quantity | itemPrice | expectedTotal
    1        | 10.00     | 10.00
    3        | 5.00      | 15.00
    0        | 9.99      | 0.00
}

Naming Strategies for Unrolled Tests

@Unroll supports full Groovy expression interpolation in test names:

@Unroll
def "#username (role: #user.role) can#access /admin"() {
    // ...
    where:
    username | user                              | access
    "alice"  | new User(role: "ADMIN")           | ""
    "bob"    | new User(role: "USER")            | "not"
}

Output:

✓ alice (role: ADMIN) can /admin
✗ bob (role: USER) cannot /admin

@Unroll at Class Level

Apply to all feature methods in a spec:

@Unroll
class ValidationSpec extends Specification {

    def "email #email is #description"() {
        expect:
        EmailValidator.isValid(email) == valid

        where:
        email              | valid | description
        "a@b.com"          | true  | "valid"
        "notanemail"       | false | "invalid"
    }

    def "phone #number is #status"() {
        expect:
        PhoneValidator.isValid(number) == valid

        where:
        number       | valid | status
        "+1234567890"| true  | "valid"
        "abc"        | false | "invalid"
    }
}

Ignoring Rows

Use @IgnoreIf or filter within the where block:

@Unroll
@IgnoreIf({ System.getenv("CI") })  // skip slow tests in CI
def "complex scenario #input"() {
    // ...
}

Or filter the data source:

where:
data << allTestData.findAll { it.enabled }

Combining @Unroll with @Retry

For flaky data-driven tests:

@Unroll
@Retry(count = 2)
def "HTTP request to #url succeeds"() {
    when:
    def response = client.get(url)

    then:
    response.status == 200

    where:
    url << externalEndpoints
}

When Data-Driven Testing Fits

Use data-driven specs when:

  • Multiple input combinations map to the same behavior
  • You're testing boundary values (0, negative, max, empty string)
  • You have a truth table (validation rules, permission checks)
  • You're testing pure functions

Avoid it when each row requires significantly different setup — separate specs are clearer in those cases.

Data-driven testing is where Spock's design pays off most visibly: the where block, @Unroll, and variable interpolation together produce parameterized tests that are easier to read, maintain, and extend than any JUnit equivalent.

Read more