This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch feature/dbcleanup-end-of-spec
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 43be3c18590490f25a1dbdbdd46266756f27963c
Author: James Daugherty <[email protected]>
AuthorDate: Wed Mar 11 10:07:05 2026 -0400

    feature - support cleaning up only after the spec ends
---
 grails-testing-support-dbcleanup-core/README.md    |  58 +++++++
 .../testing/cleanup/core/DatabaseCleanup.groovy    |  19 +++
 .../cleanup/core/DatabaseCleanupExtension.groovy   |  38 ++++-
 .../cleanup/core/DatabaseCleanupInterceptor.groovy |  40 ++++-
 .../core/DatabaseCleanupExtensionSpec.groovy       |  64 +++++++
 .../core/DatabaseCleanupInterceptorSpec.groovy     | 184 +++++++++++++++++++--
 6 files changed, 386 insertions(+), 17 deletions(-)

diff --git a/grails-testing-support-dbcleanup-core/README.md 
b/grails-testing-support-dbcleanup-core/README.md
index db1b6157c9..5ed80a37cb 100644
--- a/grails-testing-support-dbcleanup-core/README.md
+++ b/grails-testing-support-dbcleanup-core/README.md
@@ -18,6 +18,64 @@ limitations under the License.
 
 Provides the core database cleanup testing support for Grails integration 
tests, including the `@DatabaseCleanup` annotation and the `DatabaseCleaner` 
SPI.
 
+### Usage
+
+#### Basic Usage
+
+Apply `@DatabaseCleanup` at the class level to clean all datasources after 
every test method:
+
+```groovy
+@DatabaseCleanup
+class MyIntegrationSpec extends Specification { ... }
+```
+
+Apply at the method level to clean only after specific test methods:
+
+```groovy
+class MyIntegrationSpec extends Specification {
+    @DatabaseCleanup
+    void "test that modifies the database"() { ... }
+}
+```
+
+#### Specifying Datasources
+
+Clean only specific datasources (auto-discover cleaners):
+
+```groovy
+@DatabaseCleanup(['dataSource', 'dataSource_secondary'])
+class MySpec extends Specification { ... }
+```
+
+Clean specific datasources with explicit cleaner types:
+
+```groovy
+@DatabaseCleanup(['dataSource:h2', 'dataSource_pg:postgresql'])
+class MySpec extends Specification { ... }
+```
+
+#### Deferred Cleanup (cleanupAfterSpec)
+
+By default, database cleanup runs after each test method. Use 
`cleanupAfterSpec = true` to defer cleanup until after the entire spec 
finishes. This is useful when test methods build on shared data or when 
per-test cleanup is too expensive:
+
+```groovy
+@DatabaseCleanup(cleanupAfterSpec = true)
+class MySpec extends Specification {
+    void "first test creates data"() { ... }
+    void "second test uses data from first test"() { ... }
+    // Database is cleaned once after both tests complete
+}
+```
+
+**Note:** `cleanupAfterSpec` is only valid on class-level annotations. Using 
it on a method-level annotation will throw an `IllegalStateException`.
+
+#### Custom ApplicationContext Resolver
+
+```groovy
+@DatabaseCleanup(resolver = MyCustomResolver)
+class MySpec extends Specification { ... }
+```
+
 ### Supported Database Implementations
 
 Database cleanup is automatically discovered and applied based on your 
datasource configuration. The following database implementations are available:
diff --git 
a/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy
 
b/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy
index 3163daac31..68c8ea2ba7 100644
--- 
a/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy
+++ 
b/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy
@@ -75,6 +75,10 @@ import java.lang.annotation.Target
  * // Use a custom ApplicationContext resolver
  * &#64;DatabaseCleanup(resolver = MyCustomResolver)
  * class MySpec extends Specification { ... }
+ *
+ * // Clean only once after the entire spec finishes (class-level only)
+ * &#64;DatabaseCleanup(cleanupAfterSpec = true)
+ * class MySpec extends Specification { ... }
  * </pre>
  */
 @Retention(RetentionPolicy.RUNTIME)
@@ -103,4 +107,19 @@ import java.lang.annotation.Target
      * @return the resolver class to use
      */
     Class<? extends ApplicationContextResolver> resolver() default 
DefaultApplicationContextResolver
+
+    /**
+     * When {@code true}, database cleanup is deferred until after the entire 
spec finishes
+     * ({@code cleanupSpec}) instead of running after each individual test 
method.
+     *
+     * <p>This attribute is only valid on class-level annotations. Setting it 
to {@code true}
+     * on a method-level annotation will result in an {@link 
IllegalStateException} at
+     * spec visit time.</p>
+     *
+     * <p>This is useful for specs where test methods build on each other's 
data, or where
+     * per-test cleanup is too expensive and a single cleanup at the end is 
sufficient.</p>
+     *
+     * @return {@code true} to defer cleanup until after the spec finishes; 
defaults to {@code false}
+     */
+    boolean cleanupAfterSpec() default false
 }
diff --git 
a/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy
 
b/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy
index b50524f8d9..f2add1f414 100644
--- 
a/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy
+++ 
b/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy
@@ -109,10 +109,17 @@ class DatabaseCleanupExtension implements 
IGlobalExtension {
             def annotation = spec.getAnnotation(DatabaseCleanup)
             def mapping = DatasourceCleanupMapping.parse(annotation.value())
             def resolver = createResolver(annotation.resolver())
-            def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+            boolean afterSpec = annotation.cleanupAfterSpec()
+            def interceptor = new DatabaseCleanupInterceptor(context, true, 
afterSpec, mapping, resolver)
             spec.addSetupInterceptor(interceptor)
             spec.addCleanupInterceptor(interceptor)
-            log.debug('Registered DatabaseCleanupInterceptor for spec: {} 
(class-level)', spec.name)
+            if (afterSpec) {
+                spec.addCleanupSpecInterceptor(interceptor)
+                log.debug('Registered DatabaseCleanupInterceptor for spec: {} 
(class-level, cleanupAfterSpec)', spec.name)
+            }
+            else {
+                log.debug('Registered DatabaseCleanupInterceptor for spec: {} 
(class-level)', spec.name)
+            }
             return
         }
 
@@ -128,12 +135,15 @@ class DatabaseCleanupExtension implements 
IGlobalExtension {
         }
 
         if (hasMethodAnnotation) {
+            // Validate that no method-level annotation uses cleanupAfterSpec 
= true
+            validateNoMethodLevelCleanupAfterSpec(spec)
+
             // For method-level, pass a clean-all mapping as default; the 
interceptor reads
             // each method's own annotation at runtime.
             // Use the default resolver; the interceptor will read the 
method-level resolver at runtime.
             def defaultMapping = DatasourceCleanupMapping.parse(new String[0])
             def defaultResolver = new DefaultApplicationContextResolver()
-            def interceptor = new DatabaseCleanupInterceptor(context, false, 
defaultMapping, defaultResolver)
+            def interceptor = new DatabaseCleanupInterceptor(context, false, 
false, defaultMapping, defaultResolver)
             spec.addSetupInterceptor(interceptor)
             spec.addCleanupInterceptor(interceptor)
             log.debug('Registered DatabaseCleanupInterceptor for spec: {} 
(method-level)', spec.name)
@@ -192,6 +202,28 @@ class DatabaseCleanupExtension implements IGlobalExtension 
{
         }
     }
 
+    /**
+     * Validates that no method-level {@link DatabaseCleanup} annotation has
+     * {@code cleanupAfterSpec = true}. This attribute is only valid at the 
class level.
+     *
+     * @param spec the spec to validate
+     * @throws IllegalStateException if a method-level annotation has 
cleanupAfterSpec = true
+     */
+    private static void validateNoMethodLevelCleanupAfterSpec(SpecInfo spec) {
+        def invalid = spec.features.find {
+            def method = it.featureMethod
+            method.isAnnotationPresent(DatabaseCleanup) &&
+                    method.getAnnotation(DatabaseCleanup).cleanupAfterSpec()
+        }
+        if (invalid) {
+            throw new IllegalStateException(
+                    "@DatabaseCleanup(cleanupAfterSpec = true) on method 
'${invalid.featureMethod.name}' " +
+                    "in ${spec.name} is not valid. The cleanupAfterSpec 
attribute " +
+                    'can only be used on class-level @DatabaseCleanup 
annotations.'
+            )
+        }
+    }
+
     /**
      * Checks whether the spec has any {@link DatabaseCleanup} annotation, 
either at the
      * class level or on any feature method.
diff --git 
a/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy
 
b/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy
index 5e2054d389..6f2c03980d 100644
--- 
a/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy
+++ 
b/grails-testing-support-dbcleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy
@@ -44,6 +44,7 @@ class DatabaseCleanupInterceptor extends 
AbstractMethodInterceptor {
 
     private final DatabaseCleanupContext context
     private final boolean classLevelCleanup
+    private final boolean cleanupAfterSpec
     private final DatasourceCleanupMapping mapping
     private final ApplicationContextResolver resolver
 
@@ -51,6 +52,8 @@ class DatabaseCleanupInterceptor extends 
AbstractMethodInterceptor {
      * @param context the cleanup context containing the cleaners and 
configuration
      * @param classLevelCleanup if true, cleanup runs after every test method;
      *        if false, only after methods annotated with @DatabaseCleanup
+     * @param cleanupAfterSpec if true, cleanup is deferred until after the 
entire spec
+     *        finishes (cleanupSpec phase) instead of after each test method
      * @param mapping the parsed datasource-to-type mapping from the 
class-level annotation;
      *        for method-level cleanup, the method's own annotation values are 
parsed at runtime
      * @param resolver the strategy for resolving the ApplicationContext from 
test instances
@@ -58,11 +61,13 @@ class DatabaseCleanupInterceptor extends 
AbstractMethodInterceptor {
     DatabaseCleanupInterceptor(
             DatabaseCleanupContext context,
             boolean classLevelCleanup,
+            boolean cleanupAfterSpec,
             DatasourceCleanupMapping mapping,
             ApplicationContextResolver resolver
     ) {
         this.context = context
         this.classLevelCleanup = classLevelCleanup
+        this.cleanupAfterSpec = cleanupAfterSpec
         this.mapping = mapping
         this.resolver = resolver
     }
@@ -84,18 +89,26 @@ class DatabaseCleanupInterceptor extends 
AbstractMethodInterceptor {
         }
         finally {
             try {
-                def methodMapping = invocation.
+                def methodAnnotated = invocation.
                         feature?.
                         featureMethod?.
                         isAnnotationPresent(DatabaseCleanup)
-                if (!classLevelCleanup && !methodMapping) {
+
+                // When cleanupAfterSpec is true, skip class-level per-test 
cleanup —
+                // it will happen in cleanupSpec. But if the individual method 
has its own
+                // @DatabaseCleanup annotation, still honor that and clean up 
after this method.
+                if (cleanupAfterSpec && !methodAnnotated) {
+                    return
+                }
+
+                if (!classLevelCleanup && !methodAnnotated) {
                     return
                 }
                 log.debug(
                         'Performing database cleanup after test method: {}',
                         invocation.feature?.name ?: 'unknown'
                 )
-                def selectedMapping = methodMapping ?
+                def selectedMapping = methodAnnotated ?
                         getMethodMapping(invocation)
                         : mapping
                 long startTime = System.currentTimeMillis()
@@ -108,6 +121,27 @@ class DatabaseCleanupInterceptor extends 
AbstractMethodInterceptor {
         }
     }
 
+    @Override
+    void interceptCleanupSpecMethod(IMethodInvocation invocation) throws 
Throwable {
+        try {
+            invocation.proceed()
+        }
+        finally {
+            try {
+                log.debug(
+                        'Performing database cleanup after spec: {}',
+                        invocation.spec?.name ?: 'unknown'
+                )
+                long startTime = System.currentTimeMillis()
+                def stats = context.performCleanup(mapping)
+                logStats(stats, startTime)
+            }
+            finally {
+                TestContextHolderListener.CURRENT.remove()
+            }
+        }
+    }
+
     /**
      * Gets the parsed mapping from the method-level @DatabaseCleanup 
annotation.
      */
diff --git 
a/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtensionSpec.groovy
 
b/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtensionSpec.groovy
index 90dd3eb7f0..ca585762d7 100644
--- 
a/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtensionSpec.groovy
+++ 
b/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtensionSpec.groovy
@@ -183,6 +183,7 @@ class DatabaseCleanupExtensionSpec extends Specification {
         def annotatedMethod = 
MethodAnnotatedSpec.getDeclaredMethod('annotatedMethod')
         def methodInfo = Mock(MethodInfo) {
             isAnnotationPresent(DatabaseCleanup) >> true
+            getAnnotation(DatabaseCleanup) >> 
annotatedMethod.getAnnotation(DatabaseCleanup)
             getReflection() >> annotatedMethod
         }
         def feature = Mock(FeatureInfo) {
@@ -206,6 +207,59 @@ class DatabaseCleanupExtensionSpec extends Specification {
         0 * spec.addCleanupSpecInterceptor(_)
     }
 
+    def "visitSpec registers cleanupSpec interceptor when cleanupAfterSpec is 
true"() {
+        given:
+        def extension = createExtensionWithCleaner()
+
+        def spec = Mock(SpecInfo) {
+            isAnnotationPresent(SpringBootTest) >> true
+            getAnnotation(SpringBootTest) >> 
CleanupAfterSpecClassSpec.getAnnotation(SpringBootTest)
+            isAnnotationPresent(DatabaseCleanup) >> true
+            getAnnotation(DatabaseCleanup) >> 
CleanupAfterSpecClassSpec.getAnnotation(DatabaseCleanup)
+        }
+
+        when:
+        extension.visitSpec(spec)
+
+        then:
+        1 * spec.addSetupInterceptor(_ as DatabaseCleanupInterceptor)
+        1 * spec.addCleanupInterceptor(_ as DatabaseCleanupInterceptor)
+        1 * spec.addCleanupSpecInterceptor(_ as DatabaseCleanupInterceptor)
+    }
+
+    def "visitSpec throws when method-level @DatabaseCleanup has 
cleanupAfterSpec = true"() {
+        given:
+        def extension = createExtensionWithCleaner()
+
+        def annotatedMethod = 
MethodAnnotatedWithCleanupAfterSpec.getDeclaredMethod('annotatedMethod')
+        def methodAnnotation = annotatedMethod.getAnnotation(DatabaseCleanup)
+        def methodInfo = Mock(MethodInfo) {
+            isAnnotationPresent(DatabaseCleanup) >> true
+            getAnnotation(DatabaseCleanup) >> methodAnnotation
+            getName() >> 'annotatedMethod'
+        }
+        def feature = Mock(FeatureInfo) {
+            getFeatureMethod() >> methodInfo
+        }
+
+        def spec = Mock(SpecInfo) {
+            isAnnotationPresent(SpringBootTest) >> true
+            getAnnotation(SpringBootTest) >> 
MethodAnnotatedWithCleanupAfterSpec.getAnnotation(SpringBootTest)
+            isAnnotationPresent(DatabaseCleanup) >> false
+            getReflection() >> MethodAnnotatedWithCleanupAfterSpec
+            getFeatures() >> [feature]
+            getName() >> 'MethodAnnotatedWithCleanupAfterSpec'
+        }
+
+        when:
+        extension.visitSpec(spec)
+
+        then:
+        def ex = thrown(IllegalStateException)
+        ex.message.contains('cleanupAfterSpec')
+        ex.message.contains('class-level')
+    }
+
     def "visitSpec skips spec with no annotations at all"() {
         given:
         def extension = createExtensionWithCleaner()
@@ -379,4 +433,14 @@ class DatabaseCleanupExtensionSpec extends Specification {
 
         void featureMethod() {}
     }
+
+    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+    @DatabaseCleanup(cleanupAfterSpec = true)
+    static class CleanupAfterSpecClassSpec {}
+
+    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+    static class MethodAnnotatedWithCleanupAfterSpec {
+        @DatabaseCleanup(cleanupAfterSpec = true)
+        void annotatedMethod() {}
+    }
 }
diff --git 
a/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptorSpec.groovy
 
b/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptorSpec.groovy
index f99f6a08e5..066ceef490 100644
--- 
a/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptorSpec.groovy
+++ 
b/grails-testing-support-dbcleanup-core/src/test/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptorSpec.groovy
@@ -48,7 +48,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, true, false, 
mapping, resolver)
 
         def invocation = Mock(IMethodInvocation) {
             getInstance() >> new InstanceWithAppCtx(applicationContext: appCtx)
@@ -74,7 +74,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, false, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, false, 
false, mapping, resolver)
 
         def unannotatedMethod = 
NonAnnotatedTestClass.getDeclaredMethod('someTest')
         def methodInfo = Mock(MethodInfo) {
@@ -113,7 +113,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, false, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, false, 
false, mapping, resolver)
 
         def annotatedMethod = 
AnnotatedMethodTestClass.getDeclaredMethod('annotatedTest')
         def annotation = annotatedMethod.getAnnotation(DatabaseCleanup)
@@ -156,7 +156,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, false, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, false, 
false, mapping, resolver)
 
         def annotatedMethod = 
AnnotatedMethodWithDatasource.getDeclaredMethod('annotatedTest')
         def annotation = annotatedMethod.getAnnotation(DatabaseCleanup)
@@ -200,7 +200,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, false, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, false, 
false, mapping, resolver)
 
         def annotatedMethod = 
AnnotatedMethodWithExplicitType.getDeclaredMethod('annotatedTest')
         def annotation = annotatedMethod.getAnnotation(DatabaseCleanup)
@@ -248,7 +248,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, true, false, 
mapping, resolver)
 
         def invocation = Mock(IMethodInvocation) {
             getFeature() >> Mock(FeatureInfo) {
@@ -281,7 +281,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
         def resolver = Mock(ApplicationContextResolver) {
             resolve(_) >> null
         }
-        def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, true, false, 
mapping, resolver)
 
         def invocation = Mock(IMethodInvocation) {
             getInstance() >> new InstanceWithNoContext()
@@ -322,7 +322,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, true, false, 
mapping, resolver)
 
         def invocation = Mock(IMethodInvocation) {
             getFeature() >> Mock(FeatureInfo) {
@@ -361,7 +361,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, true, false, 
mapping, resolver)
 
         def invocation = Mock(IMethodInvocation) {
             getInstance() >> new InstanceWithAppCtx(applicationContext: appCtx)
@@ -419,7 +419,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, true, false, 
mapping, resolver)
 
         def invocation = Mock(IMethodInvocation) {
             getInstance() >> new InstanceWithAppCtx(applicationContext: appCtx)
@@ -463,7 +463,7 @@ class DatabaseCleanupInterceptorSpec extends Specification {
 
         def mapping = DatasourceCleanupMapping.parse(new String[0])
         def resolver = new DefaultApplicationContextResolver()
-        def interceptor = new DatabaseCleanupInterceptor(context, true, 
mapping, resolver)
+        def interceptor = new DatabaseCleanupInterceptor(context, true, false, 
mapping, resolver)
 
         def testFailure = new RuntimeException('Test failed')
         def invocation = Mock(IMethodInvocation) {
@@ -485,6 +485,168 @@ class DatabaseCleanupInterceptorSpec extends 
Specification {
         ex.is(testFailure)
     }
 
+    def "interceptCleanupMethod skips cleanup when cleanupAfterSpec is true"() 
{
+        given:
+        def dataSource = Mock(DataSource)
+        def appCtx = Mock(ApplicationContext) {
+            getBeansOfType(DataSource) >> ['dataSource': dataSource]
+        }
+        def cleaner = Mock(DatabaseCleaner) {
+            databaseType() >> 'h2'
+            supports(dataSource) >> true
+        }
+        def context = new DatabaseCleanupContext([cleaner])
+        context.applicationContext = appCtx
+
+        def mapping = DatasourceCleanupMapping.parse(new String[0])
+        def resolver = new DefaultApplicationContextResolver()
+        def interceptor = new DatabaseCleanupInterceptor(context, true, true, 
mapping, resolver)
+
+        def unannotatedMethod = 
NonAnnotatedTestClass.getDeclaredMethod('someTest')
+        def methodInfo = Mock(MethodInfo) {
+            isAnnotationPresent(DatabaseCleanup) >> false
+        }
+        def feature = Mock(FeatureInfo) {
+            getFeatureMethod() >> methodInfo
+            getName() >> 'test feature'
+        }
+        def invocation = Mock(IMethodInvocation) {
+            getInstance() >> new InstanceWithAppCtx(applicationContext: appCtx)
+            getFeature() >> feature
+        }
+
+        when:
+        interceptor.interceptCleanupMethod(invocation)
+
+        then: 'invocation is proceeded'
+        1 * invocation.proceed()
+
+        and: 'no cleanup is performed (deferred to cleanupSpec)'
+        0 * cleaner.cleanup(_, _)
+    }
+
+    def "interceptCleanupMethod still runs cleanup for method-level annotation 
even when cleanupAfterSpec is true"() {
+        given:
+        def dataSource = Mock(DataSource)
+        def appCtx = Mock(ApplicationContext) {
+            getBeansOfType(DataSource) >> ['dataSource': dataSource]
+        }
+        def cleaner = Mock(DatabaseCleaner) {
+            databaseType() >> 'h2'
+            supports(dataSource) >> true
+            cleanup(appCtx, dataSource) >> new DatabaseCleanupStats()
+        }
+        def context = new DatabaseCleanupContext([cleaner])
+        context.applicationContext = appCtx
+
+        def mapping = DatasourceCleanupMapping.parse(new String[0])
+        def resolver = new DefaultApplicationContextResolver()
+        def interceptor = new DatabaseCleanupInterceptor(context, true, true, 
mapping, resolver)
+
+        def annotatedMethod = 
AnnotatedMethodTestClass.getDeclaredMethod('annotatedTest')
+        def annotation = annotatedMethod.getAnnotation(DatabaseCleanup)
+        def methodInfo = Mock(MethodInfo) {
+            isAnnotationPresent(DatabaseCleanup) >> true
+            getAnnotation(DatabaseCleanup) >> annotation
+            getReflection() >> annotatedMethod
+        }
+        def feature = Mock(FeatureInfo) {
+            getFeatureMethod() >> methodInfo
+            getName() >> 'annotatedTest'
+        }
+        def invocation = Mock(IMethodInvocation) {
+            getInstance() >> new InstanceWithAppCtx(applicationContext: appCtx)
+            getFeature() >> feature
+        }
+
+        when:
+        interceptor.interceptCleanupMethod(invocation)
+
+        then: 'invocation is proceeded'
+        1 * invocation.proceed()
+
+        and: 'cleanup IS performed because the method has its own 
@DatabaseCleanup'
+        1 * cleaner.cleanup(appCtx, dataSource) >> new DatabaseCleanupStats()
+    }
+
+    def "interceptCleanupSpecMethod performs cleanup when cleanupAfterSpec is 
true"() {
+        given:
+        def dataSource = Mock(DataSource)
+        def appCtx = Mock(ApplicationContext) {
+            getBeansOfType(DataSource) >> ['dataSource': dataSource]
+        }
+        def cleaner = Mock(DatabaseCleaner) {
+            databaseType() >> 'h2'
+            supports(dataSource) >> true
+            cleanup(appCtx, dataSource) >> new DatabaseCleanupStats()
+        }
+        def context = new DatabaseCleanupContext([cleaner])
+        context.applicationContext = appCtx
+
+        def mapping = DatasourceCleanupMapping.parse(new String[0])
+        def resolver = new DefaultApplicationContextResolver()
+        def interceptor = new DatabaseCleanupInterceptor(context, true, true, 
mapping, resolver)
+
+        def specInfo = Mock(org.spockframework.runtime.model.SpecInfo) {
+            getName() >> 'MySpec'
+        }
+        def invocation = Mock(IMethodInvocation) {
+            getSpec() >> specInfo
+        }
+
+        when:
+        interceptor.interceptCleanupSpecMethod(invocation)
+
+        then: 'invocation is proceeded'
+        1 * invocation.proceed()
+
+        and: 'cleanup is performed'
+        1 * cleaner.cleanup(appCtx, dataSource) >> new DatabaseCleanupStats()
+    }
+
+    def "interceptCleanupSpecMethod clears ThreadLocal after cleanup"() {
+        given:
+        def dataSource = Mock(DataSource)
+        def appCtx = Mock(ApplicationContext) {
+            getBeansOfType(DataSource) >> ['dataSource': dataSource]
+        }
+        def testContext = Mock(TestContext) {
+            getApplicationContext() >> appCtx
+        }
+        TestContextHolderListener.CURRENT.set(testContext)
+
+        def cleaner = Mock(DatabaseCleaner) {
+            databaseType() >> 'h2'
+            supports(dataSource) >> true
+            cleanup(appCtx, dataSource) >> new DatabaseCleanupStats()
+        }
+        def context = new DatabaseCleanupContext([cleaner])
+        context.applicationContext = appCtx
+
+        def mapping = DatasourceCleanupMapping.parse(new String[0])
+        def resolver = new DefaultApplicationContextResolver()
+        def interceptor = new DatabaseCleanupInterceptor(context, true, true, 
mapping, resolver)
+
+        def specInfo = Mock(org.spockframework.runtime.model.SpecInfo) {
+            getName() >> 'MySpec'
+        }
+        def invocation = Mock(IMethodInvocation) {
+            getSpec() >> specInfo
+        }
+
+        when:
+        interceptor.interceptCleanupSpecMethod(invocation)
+
+        then:
+        1 * invocation.proceed()
+
+        and: 'ThreadLocal is cleared after cleanup'
+        TestContextHolderListener.CURRENT.get() == null
+
+        cleanup:
+        TestContextHolderListener.CURRENT.remove()
+    }
+
     // --- Helper classes ---
 
     static class InstanceWithAppCtx {

Reply via email to