-
-
Notifications
You must be signed in to change notification settings - Fork 972
Fix flaky grails-views-gson tests caused by shared static test state #16033
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 8.0.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ package grails.plugin.json.view.test | |
|
|
||
| import groovy.json.JsonSlurper | ||
| import groovy.text.Template | ||
| import groovy.transform.CompileDynamic | ||
| import groovy.transform.CompileStatic | ||
|
|
||
| import org.springframework.beans.factory.annotation.Autowired | ||
|
|
@@ -41,6 +42,7 @@ import grails.web.mapping.LinkGenerator | |
| import grails.web.mime.MimeUtility | ||
| import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValueMappingContext | ||
| import org.grails.datastore.mapping.model.MappingContext | ||
| import org.grails.validation.ConstraintEvalUtils | ||
|
|
||
| /** | ||
| * A trait that test classes can implement to add support for easily testing JSON views | ||
|
|
@@ -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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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.
// grails-core/.../org/grails/validation/ConstraintEvalUtils.groovy:37
static {
ShutdownOperations.addOperation({ clearDefaultConstraints() } as Runnable, true)
}
The stated mechanism also doesn't fit the failure rate. If you have a reproduction that shows otherwise I'd like to see it, since that would point at a real bug in |
||
| } | ||
|
|
||
| /** | ||
| * 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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is a no-op and can be dropped, along with the
// 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)
}
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. |
||
| if (metaClass.respondsTo(this, 'cleanupGrailsApplication')) { | ||
| cleanupGrailsApplication() | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Render a template for the given source | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| package grails.plugin.json.view | ||
|
|
||
| import tools.jackson.databind.json.JsonMapper | ||
| import grails.persistence.Entity | ||
| import grails.plugin.json.view.test.JsonRenderResult | ||
| import grails.plugin.json.view.test.JsonViewTest | ||
| import org.grails.datastore.mapping.core.Session | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the part of the PR I'd keep — an unqualified same-package reference silently binding to another spec's But it's applied to one of four specs doing exactly this, and not to the two with the worst numbers in #16030.
The same pattern holds for two other classes:
Per the dashboard the two worst specs are Could you extend the same treatment to those specs? Given |
||
| } | ||
|
|
||
| void 'Test expand parameter allows expansion of child associations'() { | ||
| given: 'An entity with a proxy association' | ||
| def mockSession = Mock(Session) | ||
| mockSession.getMappingContext() >> mappingContext | ||
| mockSession.retrieve(Team, 1L) >> new Team(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, Team, 1L) | ||
| mockSession.retrieve(ExpandTeam, 1L) >> new ExpandTeam(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, ExpandTeam, 1L) | ||
|
|
||
| def player = new Player(name: 'Cantona', team: teamProxy) | ||
| def player = new ExpandPlayer(name: 'Cantona', team: teamProxy) | ||
|
|
||
| def templateText = ''' | ||
| import grails.plugin.json.view.* | ||
| import grails.plugin.json.view.ExpandPlayer as Player | ||
|
|
||
| @Field Player player | ||
|
|
||
| json g.render(player) | ||
| ''' | ||
|
|
||
|
|
@@ -84,15 +85,13 @@ class ExpandSpec extends Specification implements JsonViewTest, GrailsUnitTest { | |
| given: 'An entity with a proxy association' | ||
| def mockSession = Mock(Session) | ||
| mockSession.getMappingContext() >> mappingContext | ||
| mockSession.retrieve(Team, 1L) >> new Team(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, Team, 1L) | ||
| mockSession.retrieve(ExpandTeam, 1L) >> new ExpandTeam(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, ExpandTeam, 1L) | ||
|
|
||
| def player = new Player(name: 'Cantona', team: teamProxy) | ||
| def player = new ExpandPlayer(name: 'Cantona', team: teamProxy) | ||
| def templateText = ''' | ||
| import grails.plugin.json.view.* | ||
|
|
||
| @Field Map map | ||
|
|
||
| json g.render(map) | ||
| ''' | ||
|
|
||
|
|
@@ -119,13 +118,13 @@ class ExpandSpec extends Specification implements JsonViewTest, GrailsUnitTest { | |
| given: 'An entity with a proxy association' | ||
| def mockSession = Mock(Session) | ||
| mockSession.getMappingContext() >> mappingContext | ||
| mockSession.retrieve(Team, 1L) >> new Team(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, Team, 1L) | ||
| mockSession.retrieve(ExpandTeam, 1L) >> new ExpandTeam(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, ExpandTeam, 1L) | ||
|
|
||
| def player = new Player(name: 'Cantona', team: teamProxy) | ||
| def player = new ExpandPlayer(name: 'Cantona', team: teamProxy) | ||
|
|
||
| def templateText = ''' | ||
| import grails.plugin.json.view.* | ||
| import grails.plugin.json.view.ExpandPlayer as Player | ||
| model { | ||
| Player player | ||
| } | ||
|
|
@@ -140,7 +139,7 @@ class ExpandSpec extends Specification implements JsonViewTest, GrailsUnitTest { | |
| { | ||
| "_links": { | ||
| "self": { | ||
| "href": "http://localhost:8080/player", | ||
| "href": "http://localhost:8080/expandPlayer", | ||
| "hreflang": "en", | ||
| "type": "application/hal+json" | ||
| } | ||
|
|
@@ -161,7 +160,7 @@ class ExpandSpec extends Specification implements JsonViewTest, GrailsUnitTest { | |
| "team": { | ||
| "_links": { | ||
| "self": { | ||
| "href": "http://localhost:8080/team/1", | ||
| "href": "http://localhost:8080/expandTeam/1", | ||
| "hreflang": "en", | ||
| "type": "application/hal+json" | ||
| } | ||
|
|
@@ -171,7 +170,7 @@ class ExpandSpec extends Specification implements JsonViewTest, GrailsUnitTest { | |
| }, | ||
| "_links": { | ||
| "self": { | ||
| "href": "http://localhost:8080/player", | ||
| "href": "http://localhost:8080/expandPlayer", | ||
| "hreflang": "en", | ||
| "type": "application/hal+json" | ||
| } | ||
|
|
@@ -185,48 +184,48 @@ class ExpandSpec extends Specification implements JsonViewTest, GrailsUnitTest { | |
| given: 'An entity with a proxy association' | ||
| def mockSession = Mock(Session) | ||
| mockSession.getMappingContext() >> mappingContext | ||
| mockSession.retrieve(Team, 9L) >> new Team(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, Team, 9L) | ||
| def player = new Player(name: 'Cantona', team: teamProxy) | ||
| mockSession.retrieve(ExpandTeam, 9L) >> new ExpandTeam(name: 'Manchester United') | ||
| def teamProxy = mappingContext.proxyFactory.createProxy(mockSession, ExpandTeam, 9L) | ||
| def player = new ExpandPlayer(name: 'Cantona', team: teamProxy) | ||
| player.id = 3 | ||
|
|
||
| when: 'The domain is rendered with expand parameters' | ||
| JsonRenderResult result = render(''' | ||
| import grails.plugin.json.view.* | ||
| import grails.plugin.json.view.ExpandPlayer as Player | ||
| model { | ||
| Player player | ||
| } | ||
|
|
||
| json jsonapi.render(player, [expand: 'team']) | ||
| ''', [player: player]) | ||
|
|
||
| then: 'The JSON relationships are in place' | ||
| objectMapper.readTree(result.jsonText) == objectMapper.readTree(''' | ||
| { | ||
| "data": { | ||
| "type": "player", | ||
| "type": "expandPlayer", | ||
| "id": "3", | ||
| "attributes": { | ||
| "name": "Cantona" | ||
| }, | ||
| "relationships": { | ||
| "team": { | ||
| "links": { | ||
| "self": "/team/9" | ||
| "self": "/expandTeam/9" | ||
| }, | ||
| "data": { | ||
| "type": "team", | ||
| "type": "expandTeam", | ||
| "id": "9" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "links": { | ||
| "self": "/player/3" | ||
| "self": "/expandPlayer/3" | ||
| }, | ||
| "included": [ | ||
| { | ||
| "type":"team", | ||
| "type":"expandTeam", | ||
| "id": "9", | ||
| "attributes": { | ||
| "titles": null, | ||
|
|
@@ -241,11 +240,33 @@ class ExpandSpec extends Specification implements JsonViewTest, GrailsUnitTest { | |
| } | ||
| }, | ||
| "links": { | ||
| "self": "/team/9" | ||
| "self": "/expandTeam/9" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| ''') | ||
| } | ||
| } | ||
|
|
||
| @Entity | ||
| class ExpandTeam { | ||
| String name | ||
| ExpandPlayer captain | ||
| List players | ||
| List<String> titles | ||
| @SuppressWarnings('unused') | ||
| static hasMany = [players: ExpandPlayer] | ||
| } | ||
|
|
||
| @Entity | ||
| class ExpandPlayer { | ||
| Long version | ||
| String name | ||
| @SuppressWarnings('unused') | ||
| static belongsTo = [team: ExpandTeam] | ||
|
|
||
| static constraints = { | ||
| name nullable: false | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good change — dropping the One consequence worth being explicit about: with the local |
||
| mappingContext.addPersistentEntities(Widget, Author, Book, ResearchPaper) | ||
| } | ||
|
|
||
| void cleanup() { | ||
| ConstraintEvalUtils.clearDefaultConstraints() | ||
| clearConstraintsMapCache(SuperHero) | ||
| } | ||
|
|
||
| /** | ||
| * Clears the private static constraintsMapInternal field in the Validateable trait. | ||
| */ | ||
| private static void clearConstraintsMapCache(Class<?> clazz) { | ||
| if (STATIC_FIELD_HELPER.isAssignableFrom(clazz)) { | ||
| def setterMethod = clazz.getMethod('grails_validation_Validateable__constraintsMapInternal$set', Map) | ||
| setterMethod.invoke(null, (Map) null) | ||
| } | ||
| } | ||
|
|
||
| void 'test simple case'() { | ||
| given: | ||
| def theWidget = new Widget(name: 'One', width: 4, height: 7) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocking: this breaks downstream specs, and there is no workaround available to the user.
JsonViewTestis a published trait. Because a Groovy trait method becomes an interface method, an implementing class must declare itpublic— but Spock's AST transform lowers the visibility of fixture methods. The two requirements are irreconcilable, so any application spec that implementsJsonViewTestand declares its owncleanup()no longer compiles. Reproduced on this branch by adding a spec with acleanup()body tograils-views-gson/src/test:cleanup()is one of the most commonly used Spock fixtures, and the user's only remedy is to delete theirs. The same applies tocleanupSpec()below. It's already biting inside this PR:JsonApiSpechad to give up its owncleanup(), 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 ingrails-testing-support-views-gson, which is already atestImplementationdependency and is the natural home for test-lifecycle behaviour. That keeps the shipped API unchanged.