Spock Extensions: @Shared, @Stepwise, and Writing Custom Extensions
Spock extensions let you hook into the test lifecycle — before specs, before features, around iterations — through a clean annotation-driven SPI. Built-in extensions like @Shared and @Stepwise solve common problems. Custom extensions give you reusable, project-specific behavior: database resets, feature flags, test timing, and more.
Built-in Extensions You Should Know
@Shared — Shared State Across Iterations
By default, Spock creates a new instance of your spec class for each feature method. This is intentional — isolation prevents one test from polluting another.
But sometimes shared setup is expensive: starting a database container, loading a fixture file, building a complex object graph. @Shared lets you share a field across all feature methods in a spec:
class DatabaseSpec extends Specification {
@Shared
def db = Database.start() // starts once for the entire spec
def cleanup() {
db.clearTestData() // runs after each feature
}
def cleanupSpec() {
db.stop() // runs after all features
}
def "insert and find user"() {
when:
db.insert(new User("alice"))
then:
db.findByName("alice").present
}
}@Shared fields are initialized once when the spec class loads. setupSpec() and cleanupSpec() also run once, making them the right place for @Shared object lifecycle.
What @Shared does not do: It doesn't isolate mutations between tests. If test A modifies a @Shared list, test B sees the modification. Design @Shared objects to be either immutable or explicitly reset in cleanup().
@Ignore and @IgnoreIf — Conditional Skipping
@Ignore("JIRA-1234: flaky on CI, investigate after release")
def "payment gateway timeout handling"() { ... }
@IgnoreIf({ System.getenv("CI") == "true" })
def "slow integration test"() { ... }
@IgnoreIf({ jvm.java8 })
def "requires Java 11+ sealed classes"() { ... }@IgnoreIf receives a closure with access to sys (system properties), env (environment variables), jvm (JVM metadata), and os (OS info).
The inverse is @Requires:
@Requires({ env.containsKey("AWS_ACCESS_KEY") })
def "uploads to S3"() { ... }@Stepwise — Sequential Test Dependencies
@Stepwise makes feature methods run in declaration order, stopping at the first failure:
@Stepwise
class UserRegistrationFlowSpec extends Specification {
def "user fills registration form"() {
when:
page.fillForm(name: "Alice", email: "alice@example.com")
page.submit()
then:
page.confirmationMessage.displayed
}
def "user verifies email"() {
// Only runs if previous test passed
when:
emailClient.clickVerificationLink()
then:
page.welcomeMessage.displayed
}
def "verified user can log in"() {
// Only runs if both previous tests passed
when:
loginPage.login("alice@example.com", password)
then:
dashboard.displayed
}
}Without @Stepwise, a failure in step 1 would still run steps 2 and 3, producing confusing failures. With it, the spec behaves like a workflow where each step depends on the previous.
When to use @Stepwise: End-to-end UI flows, multi-step API sequences where state carries over. Avoid it for unit tests — if your unit tests need to be sequential, something is wrong with your design.
@Timeout — Per-Feature Time Limits
@Timeout(5) // 5 seconds
def "cache lookup completes quickly"() {
expect:
cache.get("key") != null
}
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
def "fast path under 500ms"() { ... }Apply to the whole spec class for a default:
@Timeout(10)
class NetworkSpec extends Specification {
// all features timeout at 10s unless overridden
}@Retry — Flaky Test Handling
@Retry(count = 3, delay = 1000)
def "eventually-consistent cache returns value"() {
expect:
cache.get("key") == "value"
}Retries the entire feature on failure. Use sparingly — retries hide real flakiness. Use it for integration tests with inherent timing uncertainty, not unit tests.
The Extension SPI
Spock's extension system is built on two interfaces:
IAnnotationDrivenExtension<A>— triggered by a custom annotationIGlobalExtension— applies to every spec without annotation
Both can hook into lifecycle events by implementing methods on ISpecificationContext interceptors.
Writing a Custom Annotation Extension
Step 1: Define the annotation:
@Retention(RetentionPolicy.RUNTIME)
@Target([ElementType.TYPE, ElementType.METHOD])
@ExtensionAnnotation(DatabaseCleanupExtension)
@interface DatabaseCleanup {
String[] tables() default []
}The @ExtensionAnnotation links the annotation to the extension class that handles it.
Step 2: Implement the extension:
class DatabaseCleanupExtension implements IAnnotationDrivenExtension<DatabaseCleanup> {
@Override
void visitSpecAnnotation(DatabaseCleanup annotation, SpecInfo spec) {
// Called when the annotation is on the spec class
spec.addListener(new AbstractRunListener() {
@Override
void afterFeature(FeatureInfo feature) {
cleanup(annotation.tables())
}
})
}
@Override
void visitFeatureAnnotation(DatabaseCleanup annotation, FeatureInfo feature) {
// Called when the annotation is on a feature method
feature.spec.addListener(new AbstractRunListener() {
@Override
void afterIteration(IterationInfo iteration) {
if (iteration.feature == feature) {
cleanup(annotation.tables())
}
}
})
}
private void cleanup(String[] tables) {
def db = TestDatabaseHolder.instance
tables.each { table -> db.truncate(table) }
}
}Step 3: Use the annotation:
@DatabaseCleanup(tables = ["users", "orders"])
class OrderSpec extends Specification {
// tables are truncated after each feature
}
class MixedSpec extends Specification {
@DatabaseCleanup(tables = ["sessions"])
def "logout clears session"() { ... }
}Interceptors — Wrapping Execution
For finer control, use IMethodInterceptor to wrap execution:
class TimingExtension implements IAnnotationDrivenExtension<Timed> {
@Override
void visitFeatureAnnotation(Timed annotation, FeatureInfo feature) {
feature.addInterceptor({ IMethodInvocation invocation ->
def start = System.nanoTime()
try {
invocation.proceed()
} finally {
def elapsed = (System.nanoTime() - start) / 1_000_000
println "Feature '${invocation.feature.name}' took ${elapsed}ms"
}
})
}
}Interceptors wrap invocation.proceed() — they can run code before and after the feature, or even skip it entirely.
Lifecycle hooks available through interceptors:
| Hook | Runs |
|---|---|
addSetupSpecInterceptor |
Before setupSpec() |
addSetupInterceptor |
Before setup() (per iteration) |
addFeatureInterceptor |
Around the whole feature |
addIterationInterceptor |
Around each data-driven iteration |
addCleanupInterceptor |
After cleanup() |
addCleanupSpecInterceptor |
After cleanupSpec() |
Global Extensions
Register via META-INF/services/org.spockframework.runtime.extension.IGlobalExtension:
// src/test/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension
com.example.testing.GlobalTimingExtensionclass GlobalTimingExtension implements IGlobalExtension {
@Override
void visitSpec(SpecInfo spec) {
spec.features.each { feature ->
feature.addInterceptor({ invocation ->
def start = System.currentTimeMillis()
invocation.proceed()
def elapsed = System.currentTimeMillis() - start
if (elapsed > 5000) {
println "SLOW: ${spec.name}.${feature.name} took ${elapsed}ms"
}
})
}
}
}Global extensions apply to every spec in the test suite without any annotation.
Practical Extension: @RequiresFeatureFlag
A common use case: skip tests when a feature flag is disabled in the test environment.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ExtensionAnnotation(FeatureFlagExtension)
@interface RequiresFeatureFlag {
String value()
}
class FeatureFlagExtension implements IAnnotationDrivenExtension<RequiresFeatureFlag> {
@Override
void visitFeatureAnnotation(RequiresFeatureFlag annotation, FeatureInfo feature) {
def flagName = annotation.value()
if (!FeatureFlags.isEnabled(flagName)) {
feature.skip("Feature flag '${flagName}' is disabled")
}
}
}Usage:
@RequiresFeatureFlag("new-checkout-flow")
def "new checkout flow completes purchase"() { ... }When new-checkout-flow is off in the environment, the test is skipped with a clear reason. No assumeTrue boilerplate scattered across tests.
Spock Configuration File
Extensions can be configured through SpockConfig.groovy in the test resources root:
// src/test/resources/SpockConfig.groovy
runner {
filterStackTrace false
optimizeRunOrder false
}
report {
issueNamePrefix "JIRA-"
issueUrlPrefix "https://jira.company.com/browse/"
}The configuration file is also where you set global @Unroll behavior, parallel execution settings, and custom extension configs.
Summary
Spock's extension system gives you clean hooks into every phase of the test lifecycle. @Shared and @Stepwise solve the most common problems — expensive shared state and sequential flow tests. When those aren't enough, the IAnnotationDrivenExtension SPI lets you build reusable annotations that encode your team's testing conventions: database cleanup policies, feature flag checks, timing constraints, and more. The result is less boilerplate per test and a test suite that reflects your domain rather than framework noise.