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 once0 * 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.00order of 3 LAPTOP units costs USD 2997.00order of 5 MOUSE units costs USD 145.00order 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
#variableinterpolation 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.