Spock Extensions: @Shared, @Stepwise, and Writing Custom Extensions

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 annotation
  • IGlobalExtension — 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.GlobalTimingExtension
class 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.

Read more

Start now free