Spock Framework: BDD-Style Testing for Groovy and Java

Spock Framework: BDD-Style Testing for Groovy and Java

Spock is a Groovy-based testing framework that runs on the JVM and tests Java code perfectly well. Its given-when-then blocks enforce BDD structure, where: tables make parameterized tests readable, Mock/Stub/Spy have distinct semantics, @Unroll generates individual test names from data, and interaction-based testing with cardinality replaces verify() calls with readable expectations. With @SpringBean, Spock integrates cleanly into Spring Boot. This post covers all of it.

Spock occupies a unique position in the Java testing ecosystem: it's written in Groovy, runs on the JVM, and can test Java code without any wrapper or adapter. Because Groovy compiles to JVM bytecode, Spock specifications integrate seamlessly with Maven, Gradle, IDEs, and CI pipelines that already support JUnit. The payoff is a dramatically more readable test syntax.

The framework forces a given-when-then structure through named blocks, makes parameterized tests look like data tables, and distinguishes between three fundamentally different kinds of test doubles (Stub, Mock, Spy) rather than collapsing them all into one mock() call. Whether your codebase is Java or Groovy, Spock can be your test layer.

Project Setup

Add Spock to your Gradle build:

// build.gradle
plugins {
    id 'groovy'
    id 'java'
}

dependencies {
    testImplementation 'org.spockframework:spock-core:2.4-groovy-4.0'
    testImplementation 'org.spockframework:spock-spring:2.4-groovy-4.0'
    testImplementation 'org.apache.groovy:groovy:4.0.15'
    // For Spring Boot tests:
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform() // Spock 2.x runs on JUnit Platform
}

Maven users add:

<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-core</artifactId>
    <version>2.4-groovy-4.0</version>
    <scope>test</scope>
</dependency>

Spock spec files live in src/test/groovy with the .groovy extension.

given-when-then: BDD Structure Enforced by the Framework

Every Spock specification extends spock.lang.Specification. Tests are called "features" and use named blocks: given, when, then, and, expect, where, cleanup, setup.

The framework doesn't allow you to put assertions in when blocks (they're for actions) or assignments in then blocks (they're for assertions). This separation is enforced at compile time.

import spock.lang.*

class OrderServiceSpec extends Specification {

    OrderService orderService = new OrderService()
    InventoryRepository inventoryRepo = Stub()

    def "placing an order reduces inventory and returns confirmation"() {
        given: "a product with 10 units in stock"
        inventoryRepo.getStock("PROD-001") >> 10
        orderService.inventoryRepository = inventoryRepo

        and: "an order for 3 units"
        def request = new OrderRequest(productId: "PROD-001", quantity: 3)

        when: "the order is placed"
        def result = orderService.placeOrder(request)

        then: "inventory is reduced"
        1 * inventoryRepo.reduceStock("PROD-001", 3)

        and: "a confirmation is returned"
        result.status == OrderStatus.CONFIRMED
        result.orderId != null
        result.quantity == 3
    }

    def "placing an order for more units than in stock throws InsufficientInventoryException"() {
        given:
        inventoryRepo.getStock("PROD-001") >> 2
        orderService.inventoryRepository = inventoryRepo

        def request = new OrderRequest(productId: "PROD-001", quantity: 5)

        when:
        orderService.placeOrder(request)

        then:
        def ex = thrown(InsufficientInventoryException)
        ex.productId == "PROD-001"
        ex.requested == 5
        ex.available == 2
    }
}

The then block reads as a list of assertions. Each expression that evaluates to false causes the test to fail. No explicit assertEquals or assertThat calls — Groovy's power assertions show exactly what was expected vs actual in the failure output.

The expect Block

For pure functions without setup, expect is cleaner than when/then:

def "tax calculator returns correct amount for each tier"() {
    expect:
    taxCalculator.calculate(income, region) == expectedTax

    where:
    income          | region | expectedTax
    10_000          | "CA"   | 1_000
    50_000          | "CA"   | 6_250
    100_000         | "CA"   | 15_000
    10_000          | "TX"   | 0        // no state income tax
    50_000          | "TX"   | 0
}

where: Tables for Parameterized Tests

The where: block is Spock's most visually distinctive feature. It defines a data table where the header row names the variables and each subsequent row is one test invocation.

class PasswordValidatorSpec extends Specification {

    PasswordValidator validator = new PasswordValidator(minLength: 8)

    def "validates password strength correctly"() {
        expect:
        validator.validate(password).valid == isValid
        validator.validate(password).score == expectedScore

        where:
        password            || isValid | expectedScore
        "short"             || false   | 1
        "longenough"        || false   | 2  // no uppercase/number
        "LongEnough1"       || true    | 3  // meets minimum
        "L0ngEnough!Str0ng" || true    | 5  // maximum score
        ""                  || false   | 0  // empty
        null                || false   | 0  // null
    }

    def "rejects passwords from the common list"() {
        expect:
        !validator.validate(commonPassword).valid

        where:
        commonPassword << ["password", "123456", "qwerty", "abc123", "admin"]
    }
}

The double pipe || is purely visual — it separates inputs from expected values semantically but has no runtime effect. Use it consistently for readability.

Where with Method Sources

When test data is too complex for inline tables, pull from methods:

def "processes all country-specific tax rules"() {
    given:
    def calculator = new InternationalTaxCalculator()

    expect:
    calculator.computeTax(scenario.income, scenario.country) == scenario.expectedTax

    where:
    scenario << loadTaxScenarios()
}

private List<TaxScenario> loadTaxScenarios() {
    // Load from YAML, database, or generate programmatically
    return TaxScenarioLoader.loadAll("tax-test-scenarios.yaml")
}

Mock, Stub, and Spy: Three Different Semantics

Spock makes a hard distinction between the three types of test doubles. This isn't just terminology — they work differently and enforce different constraints.

Stub: Return Values, No Verification

A Stub is used when you need a collaborator to return controlled values. You can add interactions (method calls with return values), but the test will not fail if those methods are never called.

class PricingServiceSpec extends Specification {

    def "calculates discounted price using current exchange rate"() {
        given:
        def exchangeService = Stub(ExchangeRateService)
        exchangeService.getRate("USD", "EUR") >> 0.92  // always returns 0.92

        def pricingService = new PricingService(exchangeRate: exchangeService)

        when:
        def euroPrice = pricingService.convertToEuro(Money.of(100, "USD"))

        then:
        euroPrice == Money.of(92, "EUR")
    }
}

You can set up stub responses based on arguments:

exchangeService.getRate("USD", _) >> { String from, String to ->
    to == "EUR" ? 0.92 : to == "GBP" ? 0.79 : 1.0
}

The _ wildcard matches any argument.

Mock: Verification + Return Values

A Mock verifies that interactions happened. If you declare 1 * service.method() in the then block, the test fails if that method was never called (or called more than once).

class NotificationServiceSpec extends Specification {

    def emailSender = Mock(EmailSender)
    def smsSender = Mock(SmsSender)
    def notificationService = new NotificationService(
        emailSender: emailSender,
        smsSender: smsSender
    )

    def "sends email and SMS for critical alerts"() {
        given:
        def alert = new Alert(level: AlertLevel.CRITICAL, message: "Database down")

        when:
        notificationService.send(alert)

        then:
        1 * emailSender.send({ it.subject.contains("CRITICAL") })
        1 * smsSender.send(_ as String)
        0 * _  // no other interactions allowed (strict mode for remaining)
    }

    def "sends only email for informational alerts"() {
        given:
        def alert = new Alert(level: AlertLevel.INFO, message: "Backup completed")

        when:
        notificationService.send(alert)

        then:
        1 * emailSender.send(_)
        0 * smsSender.send(_)  // SMS must NOT be called
    }
}

Cardinality expressions in then: blocks:

  • 1 * method() — exactly once
  • 0 * method() — never
  • (1..3) * method() — one to three times
  • (_) * method() — any number (equivalent to _ *)
  • (1.._) * method() — at least once

Spy: Partial Mocking of Real Objects

A Spy wraps a real object. Methods you don't override call through to the real implementation. Methods you stub return controlled values.

def "spy intercepts specific methods while calling real implementation for others"() {
    given:
    def realCalculator = new TaxCalculator()
    def spy = Spy(TaxCalculator, constructorArgs: [TaxConfig.defaults()])

    // Override just the EU rate lookup
    spy.getEURate() >> 0.20

    when:
    // This calls real calculate() which internally calls getEURate() (stubbed)
    def result = spy.calculateEUTax(Money.of(1000, "USD"))

    then:
    result == Money.of(200, "USD")  // 20% of 1000
}

Use Spy when you need to test a method that collaborates with other methods in the same class, but you want to isolate those collaborating methods.

@Unroll: Descriptive Test Names from Data

Without @Unroll, a parameterized test shows as one entry in test reports (e.g., "validates password strength correctly [1 iteration]"). With @Unroll, each row becomes a separate named test using #variable interpolation.

@Unroll
def "order of #quantity #product units costs #expectedTotal"() {
    given:
    def catalog = Stub(ProductCatalog)
    catalog.getPrice("LAPTOP") >> Money.of(999, "USD")
    catalog.getPrice("MOUSE") >> Money.of(29, "USD")

    def orderService = new OrderService(catalog: catalog)

    when:
    def total = orderService.calculateTotal(
        new OrderRequest(productId: product, quantity: quantity)
    )

    then:
    total == expectedTotal

    where:
    product  | quantity | expectedTotal
    "LAPTOP" | 1        | Money.of(999, "USD")
    "LAPTOP" | 3        | Money.of(2997, "USD")
    "MOUSE"  | 5        | Money.of(145, "USD")
    "MOUSE"  | 10       | Money.of(290, "USD")
}

In your test report, you'll see four separate entries:

  • order of 1 LAPTOP units costs USD 999.00
  • order of 3 LAPTOP units costs USD 2997.00
  • order of 5 MOUSE units costs USD 145.00
  • order of 10 MOUSE units costs USD 290.00

Since Spock 2.0, @Unroll is the default behavior — you can configure it globally in SpockConfig.groovy:

// src/test/resources/SpockConfig.groovy
runner {
    unrollByDefault true
}

Interaction-Based Testing with Cardinality

Spock's interaction model lets you define expected method calls with argument constraints and cardinality, all in the readable then: block. This replaces Mockito's verify() calls with something much closer to a specification.

class ShoppingCartSpec extends Specification {

    def inventoryService = Mock(InventoryService)
    def auditLog = Mock(AuditLog)
    def cart = new ShoppingCart(inventory: inventoryService, audit: auditLog)

    def "adding a high-value item triggers audit log entry"() {
        when:
        cart.addItem(new CartItem(productId: "LAPTOP-PRO", price: Money.of(2500, "USD"), quantity: 2))

        then: "inventory is reserved exactly once"
        1 * inventoryService.reserve("LAPTOP-PRO", 2)

        and: "audit log records the high-value addition with correct data"
        1 * auditLog.record({
            it.eventType == AuditEventType.HIGH_VALUE_CART_ADDITION &&
            it.productId == "LAPTOP-PRO" &&
            it.totalValue.amount > 1000
        })

        and: "no other audit entries are created"
        0 * auditLog.record({ it.eventType != AuditEventType.HIGH_VALUE_CART_ADDITION })
    }

    def "adding a low-value item skips audit log"() {
        when:
        cart.addItem(new CartItem(productId: "PEN", price: Money.of(1.99, "USD"), quantity: 1))

        then:
        1 * inventoryService.reserve("PEN", 1)
        0 * auditLog._  // no audit methods called at all
    }
}

The argument constraint { it.eventType == ... } is a closure that evaluates for each call. If the constraint returns false, the interaction doesn't match, and Spock reports a failure.

Testing Spring Boot with @SpringBean

Spock's Spring integration (spock-spring) lets you inject Spring beans into specs. @SpringBean declares a Spock mock that is injected into the application context, replacing the real bean:

@SpringBootTest
class UserRegistrationSpec extends Specification {

    @Autowired
    UserRegistrationService registrationService

    @SpringBean
    EmailService emailService = Mock()

    @SpringBean
    StripeService stripeService = Mock()

    def "successful registration sends welcome email and creates free trial"() {
        given:
        def request = new RegistrationRequest(
            email: "newuser@example.com",
            name: "Alice Smith",
            plan: "free"
        )
        stripeService.createCustomer(_ as String) >> new StripeCustomer(id: "cus_test123")

        when:
        def user = registrationService.register(request)

        then:
        user.id != null
        user.email == "newuser@example.com"

        and: "welcome email sent with correct recipient"
        1 * emailService.sendWelcome({ it.to == "newuser@example.com" })

        and: "Stripe customer created"
        1 * stripeService.createCustomer("newuser@example.com")
    }

    def "registration fails gracefully when email service is down"() {
        given:
        emailService.sendWelcome(_) >> { throw new EmailServiceException("SMTP timeout") }

        when:
        def request = new RegistrationRequest(
            email: "test@example.com",
            name: "Bob",
            plan: "free"
        )
        registrationService.register(request)

        then:
        def ex = thrown(RegistrationException)
        ex.cause instanceof EmailServiceException

        and: "user was NOT persisted"
        0 * stripeService.createCustomer(_)
    }
}

For slice tests (testing only the web layer or repository layer):

@WebMvcTest(UserController)
class UserControllerSpec extends Specification {

    @Autowired
    MockMvc mockMvc

    @SpringBean
    UserService userService = Mock()

    def "GET /users/{id} returns 200 with user data"() {
        given:
        userService.findById(42L) >> new User(id: 42L, email: "alice@example.com", name: "Alice")

        when:
        def result = mockMvc.perform(get("/users/42")
            .accept(MediaType.APPLICATION_JSON))

        then:
        result.andExpect(status().isOk())
              .andExpect(jsonPath('$.email').value("alice@example.com"))
              .andExpect(jsonPath('$.name').value("Alice"))
    }

    def "GET /users/{id} returns 404 when user not found"() {
        given:
        userService.findById(999L) >> { throw new UserNotFoundException(999L) }

        when:
        def result = mockMvc.perform(get("/users/999"))

        then:
        result.andExpect(status().isNotFound())
    }
}

Exception Handling Patterns

Spock's exception testing is more expressive than assertThrows:

def "parsing invalid JSON throws ParseException with location info"() {
    when:
    jsonParser.parse("{ invalid json }")

    then:
    def ex = thrown(ParseException)
    ex.offset == 2         // character position of error
    ex.message =~ /Unexpected token/  // regex match
}

def "method does not throw for valid inputs"() {
    when:
    jsonParser.parse('{"valid": true}')

    then:
    notThrown(ParseException)
}

Cleanup and @Shared Fields

Use cleanup: blocks for teardown (runs even if the test fails), and @Shared for fields that persist across feature methods (expensive objects like DB connections):

class DatabaseSpec extends Specification {

    @Shared
    DataSource dataSource = createDataSource()  // created once for the spec class

    Connection connection  // created fresh per feature method

    def setup() {
        connection = dataSource.getConnection()
    }

    def cleanup() {
        connection?.rollback()
        connection?.close()
    }

    def "saves and retrieves a user"() {
        given:
        def user = new User(email: "test@example.com")

        when:
        userRepo.save(user)
        def found = userRepo.findByEmail("test@example.com")

        then:
        found.isPresent()
        found.get().email == "test@example.com"
    }
}

Summary

Spock brings a genuinely different approach to JVM testing:

  • given-when-then blocks enforce BDD structure at the framework level — readable tests that document behavior
  • where: tables make parameterized tests look like the requirements they're derived from
  • Stub vs Mock vs Spy have distinct semantics that prevent test double misuse and make intent explicit
  • @Unroll with #variable interpolation generates descriptive test names that appear in CI reports and failure messages
  • Interaction-based testing with cardinality (1 *, 0 *, (1..3) *) reads as a specification, not as an assertion
  • @SpringBean drops Spock mocks into the Spring context, enabling clean integration tests without @MockBean's verbosity

For teams that can adopt Groovy in their test layer, Spock tends to produce the most readable test code on the JVM. For existing Groovy shops, it's the obvious choice. For Java teams, the setup cost (adding Groovy compilation) pays off quickly in test maintainability.

Read more

Start now free