jdaugherty commented on code in PR #16033:
URL: https://github.com/apache/grails-core/pull/16033#discussion_r3652225989


##########
grails-views-gson/src/test/groovy/grails/plugin/json/view/api/JsonApiSpec.groovy:
##########
@@ -24,39 +24,23 @@ import grails.plugin.json.view.test.JsonRenderResult
 import grails.plugin.json.view.test.JsonViewTest
 import grails.validation.Validateable
 import org.grails.testing.GrailsUnitTest
-import org.grails.validation.ConstraintEvalUtils
 import spock.lang.Shared
 import spock.lang.Specification
 
 class JsonApiSpec extends Specification implements JsonViewTest, 
GrailsUnitTest {
 
-    // Cache the static field helper interface for performance
-    private static final Class<?> STATIC_FIELD_HELPER = 
Class.forName('grails.validation.Validateable$Trait$StaticFieldHelper')
-
     @Shared
     JsonMapper objectMapper = JsonMapper.builder().build()
 
     void setup() {
-        ConstraintEvalUtils.clearDefaultConstraints()
-        clearConstraintsMapCache(SuperHero)
+        // Resets SuperHero's own cached constraints map (see 
Validateable#clearConstraintsMapCache).
+        // JsonViewTest#cleanup() already resets the shared 
ConstraintEvalUtils cache after every
+        // feature, but SuperHero's cache is specific to this spec's own 
Validateable command
+        // object, so it is reset here too.
+        SuperHero.clearConstraintsMapCache()

Review Comment:
   Good change — dropping the `Validateable$Trait$StaticFieldHelper` reflection 
in favour of the public `clearConstraintsMapCache()` is exactly right, and it 
matches how `grails-validation`'s own specs do it (`ValidateableTraitSpec:46`, 
`ValidateableTraitAdHocSpec:37`). It also brings this spec in line with the 
project rule about testing through public APIs.
   
   One consequence worth being explicit about: with the local `cleanup()` gone, 
`SuperHero`'s cache is now only reset on the way *in*, so the last feature of 
this spec leaves it populated for the rest of the fork. That's harmless today — 
`SuperHero` is declared and used only in this file — but it's the opposite of 
the isolation-by-default goal, and note that you can't fix it by re-adding a 
`cleanup()` here as long as the trait declares one. Another argument for 
keeping the fixture methods out of the published trait.



##########
grails-views-gson/src/main/groovy/grails/plugin/json/view/test/JsonViewTest.groovy:
##########
@@ -104,6 +106,43 @@ trait JsonViewTest {
         return templateEngine
     }()
 
+    /**
+     * Resets the {@link ConstraintEvalUtils} default-constraints cache after 
every feature
+     * method so state from one test cannot leak into the next test that 
happens to run in the
+     * same JVM/fork.
+     *
+     * <p>{@link ConstraintEvalUtils} memoizes the default GORM constraints 
map in a single
+     * JVM-wide static field keyed by the identity of the last {@code Config} 
seen. Since each
+     * spec implementing this trait typically builds its own {@code 
GrailsApplication}/{@code
+     * Config}, a stale entry left behind by one spec can otherwise be picked 
up by another.
+     * This is cheap to recompute, so it is safe to clear after every 
feature.</p>
+     */
+    void cleanup() {
+        ConstraintEvalUtils.clearDefaultConstraints()
+    }
+
+    /**
+     * Tears down any {@code GrailsApplication} cached by {@code 
org.grails.testing.GrailsUnitTest}
+     * once the whole spec has finished, so a later spec running in the same 
JVM/fork always starts
+     * from a clean application context.
+     *
+     * <p>This is deliberately a {@code cleanupSpec()} (once per spec class), 
not a per-feature
+     * {@code cleanup()}: {@code GrailsUnitTest} intentionally builds its 
{@code GrailsApplication}
+     * lazily and reuses it across every feature of a spec (it is expensive to 
build and some
+     * traits, e.g. {@code DataTest}, register beans into it once per spec). 
Tearing it down after
+     * every feature would rebuild it before the next feature could see those 
spec-scoped beans.</p>
+     *
+     * <p>{@code GrailsUnitTest} lives in {@code grails-testing-support-core}, 
a test-only
+     * dependency this trait cannot reference directly since it ships as part 
of the main
+     * {@code grails-views-gson} artifact, so the call is made dynamically 
only when present.</p>
+     */
+    @CompileDynamic
+    void cleanupSpec() {

Review Comment:
   This method is a no-op and can be dropped, along with the `@CompileDynamic` 
annotation and the `groovy.transform.CompileDynamic` import.
   
   `cleanupGrailsApplication()` is already invoked after every spec class that 
implements `GrailsUnitTest`, via the global Spock extension in 
`grails-testing-support-core`:
   
   ```groovy
   // org/grails/testing/spock/TestingSupportExtension.groovy:51 (registered in
   // META-INF/services/org.spockframework.runtime.extension.IGlobalExtension)
   if (GrailsUnitTest.isAssignableFrom(spec.reflection)) {
       spec.addCleanupSpecInterceptor(cleanupContextInterceptor)
   }
   ```
   
   `CleanupContextInterceptor` calls `cleanupGrailsApplication()` in a 
`finally` block, and Spock attaches cleanup-spec interceptors to a synthetic 
`CLEANUP_SPEC` `MethodInfo` that runs for every spec whether or not one is 
declared (`PlatformSpecRunner.createMethodForDoRunCleanupSpec`, spock-core 
2.4). So for a `GrailsUnitTest` spec this trait method runs inside 
`invocation.proceed()`, nulls `_grailsApplication`, and the interceptor's own 
call then finds null and no-ops — net behaviour identical to 8.0.x. For a spec 
that doesn't implement `GrailsUnitTest`, `respondsTo` is false and nothing 
happens at all.
   
   That matters beyond tidiness: this is the half of the change that adds a 
second breaking fixture method to the published trait, for no behavioural gain.



##########
grails-views-gson/src/test/groovy/grails/plugin/json/view/ExpandSpec.groovy:
##########
@@ -32,23 +33,23 @@ class ExpandSpec extends Specification implements 
JsonViewTest, GrailsUnitTest {
     JsonMapper objectMapper = JsonMapper.builder().build()
 
     void setup() {
-        mappingContext.addPersistentEntities(Team, Player)
+        mappingContext.addPersistentEntities(ExpandTeam, ExpandPlayer)

Review Comment:
   This is the part of the PR I'd keep — an unqualified same-package reference 
silently binding to another spec's `@Entity` classes is a real trap, and giving 
`ExpandSpec` its own fixtures is the right shape of fix.
   
   But it's applied to one of four specs doing exactly this, and not to the two 
with the worst numbers in #16030. `Team` and `Player` are declared once, in 
`JsonViewHelperSpec.groovy:663` and `:672`, and after this PR they are still 
registered into three other independently-built `KeyValueMappingContext` 
instances:
   
   - `IncludeAssociationsSpec` — `import grails.plugin.json.view.*` plus 
`addPersistentEntities(Player, Team)`
   - `HalEmbeddedSpec` — same-package unqualified reference, 
`addPersistentEntities(Team, Player)`
   - `IterableRenderSpec` — same-package unqualified reference, 
`addPersistentEntities(Player, Team)`, in five separate features
   
   The same pattern holds for two other classes:
   
   - `grails.plugin.json.view.api.Author` is declared in 
`api/JsonApiSpec.groovy:449` and also registered by 
`api/JsonApiHandleAssociationsSpec` (`addPersistentEntities(Author, 
PublishedBook, Publisher)`)
   - `Person` is declared in `EmbeddedAssociationsSpec.groovy:180` and also 
registered by `HalEmbeddedSpec` (`addPersistentEntities(Person, Parent)`)
   
   Per the dashboard the two worst specs are `JsonViewHelperSpec` (8 methods, 
20 failures) and `JsonApiSpec` (7 methods, 20 failures) — and both still share 
entity classes with another spec after this change. `ExpandSpec` (19 failures) 
is the only one decoupled. So if class-keyed GORM state is the root cause, the 
flakiness should survive this PR.
   
   Could you extend the same treatment to those specs? Given `Team`/`Player` 
are wanted by four specs, a shared read-only fixture file plus one mapping 
context, or per-spec copies as done here, would both work — the important thing 
is that no two independently-built mapping contexts see the same `Class`. A 
dedicated fixture source file would also make the ownership obvious and stop 
the next spec from picking them up by accident.



##########
grails-views-gson/src/main/groovy/grails/plugin/json/view/test/JsonViewTest.groovy:
##########
@@ -104,6 +106,43 @@ trait JsonViewTest {
         return templateEngine
     }()
 
+    /**
+     * Resets the {@link ConstraintEvalUtils} default-constraints cache after 
every feature
+     * method so state from one test cannot leak into the next test that 
happens to run in the
+     * same JVM/fork.
+     *
+     * <p>{@link ConstraintEvalUtils} memoizes the default GORM constraints 
map in a single
+     * JVM-wide static field keyed by the identity of the last {@code Config} 
seen. Since each
+     * spec implementing this trait typically builds its own {@code 
GrailsApplication}/{@code
+     * Config}, a stale entry left behind by one spec can otherwise be picked 
up by another.
+     * This is cheap to recompute, so it is safe to clear after every 
feature.</p>
+     */
+    void cleanup() {
+        ConstraintEvalUtils.clearDefaultConstraints()

Review Comment:
   Separately from the API concern: I don't think this clearing is 
load-bearing, because the cache is already reset once per spec class today.
   
   `ConstraintEvalUtils`' static initialiser registers its own reset as a 
*preserved* shutdown operation:
   
   ```groovy
   // grails-core/.../org/grails/validation/ConstraintEvalUtils.groovy:37
   static {
       ShutdownOperations.addOperation({ clearDefaultConstraints() } as 
Runnable, true)
   }
   ```
   
   `true` is `preserveForNextShutdown`, so `ShutdownOperations.runOperations()` 
re-adds it after each run and it fires every time. 
`GrailsUnitTest.cleanupGrailsApplication()` calls `runOperations()` 
(`GrailsUnitTest.groovy:184`), and nothing in the repo calls 
`ShutdownOperations.resetOperations()` — the only thing that would drop a 
preserved operation. Since the cache can only be non-empty if 
`ConstraintEvalUtils` has been loaded, and loading is what registers the reset, 
any populated cache is guaranteed to be cleared at the end of the spec that 
populated it. So cross-spec leakage of this cache is already impossible for 
`GrailsUnitTest` specs; this change only moves it from per-spec to per-feature, 
and a single spec has a single `Config`.
   
   The stated mechanism also doesn't fit the failure rate. 
`getDefaultConstraints(config)` recomputes unless `configId == 
System.identityHashCode(config)`, so returning a stale map requires an 
identity-hash collision between two *consecutive* `Config` instances — roughly 
1 in 2^31, not ~1%.
   
   If you have a reproduction that shows otherwise I'd like to see it, since 
that would point at a real bug in `ConstraintEvalUtils`' identity-hash keying — 
which would be worth fixing there (e.g. a `WeakHashMap` keyed on the `Config` 
itself) rather than papered over from a test trait.



##########
grails-views-gson/src/main/groovy/grails/plugin/json/view/test/JsonViewTest.groovy:
##########
@@ -104,6 +106,43 @@ trait JsonViewTest {
         return templateEngine
     }()
 
+    /**
+     * Resets the {@link ConstraintEvalUtils} default-constraints cache after 
every feature
+     * method so state from one test cannot leak into the next test that 
happens to run in the
+     * same JVM/fork.
+     *
+     * <p>{@link ConstraintEvalUtils} memoizes the default GORM constraints 
map in a single
+     * JVM-wide static field keyed by the identity of the last {@code Config} 
seen. Since each
+     * spec implementing this trait typically builds its own {@code 
GrailsApplication}/{@code
+     * Config}, a stale entry left behind by one spec can otherwise be picked 
up by another.
+     * This is cheap to recompute, so it is safe to clear after every 
feature.</p>
+     */
+    void cleanup() {

Review Comment:
   Blocking: this breaks downstream specs, and there is no workaround available 
to the user.
   
   `JsonViewTest` is a published trait. Because a Groovy trait method becomes 
an interface method, an implementing class must declare it `public` — but 
Spock's AST transform lowers the visibility of fixture methods. The two 
requirements are irreconcilable, so any application spec that implements 
`JsonViewTest` and declares its own `cleanup()` no longer compiles. Reproduced 
on this branch by adding a spec with a `cleanup()` body to 
`grails-views-gson/src/test`:
   
   ```
   > Task :grails-views-gson:compileTestGroovy FAILED
   startup failed:
   .../TmpUserCleanupProbeSpec.groovy: 29: The method cleanup should be public 
as it implements
   the corresponding method from interface 
grails.plugin.json.view.test.JsonViewTest
   . At [29:5]  @ line 29, column 5.
          void cleanup() {
          ^
   ```
   
   `cleanup()` is one of the most commonly used Spock fixtures, and the user's 
only remedy is to delete theirs. The same applies to `cleanupSpec()` below. 
It's already biting inside this PR: `JsonApiSpec` had to give up its own 
`cleanup()`, not because the teardown was unnecessary but because it can no 
longer declare one.
   
   If per-feature isolation is wanted for this module's specs, it belongs in a 
test-scoped fixture — a base spec or trait under `grails-views-gson/src/test`, 
or in `grails-testing-support-views-gson`, which is already a 
`testImplementation` dependency and is the natural home for test-lifecycle 
behaviour. That keeps the shipped API unchanged.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to