Skip to content

Commit 287f718

Browse files
[Spring] Instantiate TestContextManager synchronously (#2687)
In brief, the problem is that invoking `new TestContextManager(someContextConfiguration)` for identical instances of someContextConfiguration is not actually thread-safe. Spring caches the configuration derived from `someContextConfiguration` and when AOT is used, will fail to apply idempotent updates configuration. Fixes: #2686 Co-authored-by: M.P. Korstanje <[email protected]>
1 parent e5ff5f6 commit 287f718

File tree

5 files changed

+52
-34
lines changed

5 files changed

+52
-34
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
.settings
77
.project
88
.classpath
9+
lib/
910

1011

1112
# Build directories
@@ -18,7 +19,7 @@ out/
1819
# Build & test droppings
1920
pom.xml.releaseBackup
2021
pom.xml.versionsBackup
21-
release.properties
22+
release.propertiesF
2223
*.ser
2324
dependency-reduced-pom.xml
2425
*~

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
1010
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1111

1212
## [Unreleased]
13+
### Fixed
14+
- [Spring] Instantiate `TestContextManager` synchronously ([#2686](https://github.com/cucumber/cucumber-jvm/pull/2686), [#2687](https://github.com/cucumber/cucumber-jvm/pull/2687) Thai Nguyen, M.P. Korstanje)
15+
1316
### Added
1417
- [Core] Warn when `cucumber.options` is used ([#2685](https://github.com/cucumber/cucumber-jvm/pull/2685) M.P. Korstanje)
1518

cucumber-spring/src/main/java/io/cucumber/spring/SpringFactory.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import java.util.Collection;
1818
import java.util.HashSet;
1919

20+
import static io.cucumber.spring.TestContextAdaptor.create;
21+
2022
/**
2123
* Spring based implementation of ObjectFactory.
2224
* <p>
@@ -116,8 +118,7 @@ public void start() {
116118
// The application context created by the TestContextManager is
117119
// a singleton and reused between scenarios and shared between
118120
// threads.
119-
TestContextManager testContextManager = new TestContextManager(withCucumberContextConfiguration);
120-
testContextAdaptor = new TestContextAdaptor(testContextManager, stepClasses);
121+
testContextAdaptor = create(() -> new TestContextManager(withCucumberContextConfiguration), stepClasses);
121122
testContextAdaptor.start();
122123
}
123124

cucumber-spring/src/main/java/io/cucumber/spring/TestContextAdaptor.java

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,41 +13,51 @@
1313
import java.util.ArrayDeque;
1414
import java.util.Collection;
1515
import java.util.Deque;
16+
import java.util.function.Supplier;
1617

1718
import static io.cucumber.spring.CucumberTestContext.SCOPE_CUCUMBER_GLUE;
1819
import static org.springframework.beans.factory.config.AutowireCapableBeanFactory.AUTOWIRE_CONSTRUCTOR;
1920

2021
class TestContextAdaptor {
2122

2223
private static final Object monitor = new Object();
23-
2424
private final TestContextManager delegate;
2525
private final ConfigurableApplicationContext applicationContext;
26-
private final Collection<Class<?>> glueClasses;
2726
private final Deque<Runnable> stopInvocations = new ArrayDeque<>();
2827
private Object delegateTestInstance;
2928

30-
TestContextAdaptor(
31-
TestContextManager delegate,
29+
static TestContextAdaptor create(
30+
Supplier<TestContextManager> testContextManagerSupplier,
3231
Collection<Class<?>> glueClasses
3332
) {
34-
TestContext testContext = delegate.getTestContext();
35-
ConfigurableApplicationContext applicationContext = (ConfigurableApplicationContext) testContext
36-
.getApplicationContext();
37-
this.delegate = delegate;
38-
this.applicationContext = applicationContext;
39-
this.glueClasses = glueClasses;
40-
}
41-
42-
public final void start() {
43-
// The TestContextManager delegate makes the application context
44-
// available to other threads. Registering the glue however modifies the
45-
// application context. To avoid concurrent modification issues (#1823,
46-
// #1153, #1148, #1106) we do this serially.
4733
synchronized (monitor) {
34+
// While under construction, the TestContextManager delegate will
35+
// build a cached version of the application context configuration.
36+
// Since Spring Boot 3 and in combination with AOT building this
37+
// configuration is not idempotent (#2686).
38+
TestContextManager delegate = testContextManagerSupplier.get();
39+
40+
TestContext testContext = delegate.getTestContext();
41+
ConfigurableApplicationContext applicationContext = (ConfigurableApplicationContext) testContext
42+
.getApplicationContext();
43+
44+
// The TestContextManager delegate makes the application context
45+
// available to other threads. Registering the glue however modifies
46+
// the application context. To avoid concurrent modification issues
47+
// (#1823, #1153, #1148, #1106) we do this serially.
4848
registerGlueCodeScope(applicationContext);
49-
registerStepClassBeanDefinitions(applicationContext.getBeanFactory());
49+
registerStepClassBeanDefinitions(applicationContext.getBeanFactory(), glueClasses);
50+
51+
return new TestContextAdaptor(delegate);
5052
}
53+
}
54+
55+
TestContextAdaptor(TestContextManager delegate) {
56+
this.delegate = delegate;
57+
this.applicationContext = (ConfigurableApplicationContext) delegate.getTestContext().getApplicationContext();
58+
}
59+
60+
final void start() {
5161
stopInvocations.push(this::notifyTestContextManagerAboutAfterTestClass);
5262
notifyContextManagerAboutBeforeTestClass();
5363
stopInvocations.push(this::stopCucumberTestContext);
@@ -125,7 +135,7 @@ private void notifyTestContextManagerAboutBeforeTestMethod() {
125135
}
126136
}
127137

128-
private void registerGlueCodeScope(ConfigurableApplicationContext context) {
138+
private static void registerGlueCodeScope(ConfigurableApplicationContext context) {
129139
while (context != null) {
130140
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
131141
// Scenario scope may have already been registered by another
@@ -146,14 +156,16 @@ private void notifyTestContextManagerAboutBeforeExecution() {
146156
}
147157
}
148158

149-
private void registerStepClassBeanDefinitions(ConfigurableListableBeanFactory beanFactory) {
159+
private static void registerStepClassBeanDefinitions(
160+
ConfigurableListableBeanFactory beanFactory, Collection<Class<?>> glueClasses
161+
) {
150162
BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory;
151163
for (Class<?> glue : glueClasses) {
152164
registerStepClassBeanDefinition(registry, glue);
153165
}
154166
}
155167

156-
private void registerStepClassBeanDefinition(BeanDefinitionRegistry registry, Class<?> glueClass) {
168+
private static void registerStepClassBeanDefinition(BeanDefinitionRegistry registry, Class<?> glueClass) {
157169
String beanName = glueClass.getName();
158170
// Step definition may have already been
159171
// registered as a bean by another thread.
@@ -166,7 +178,7 @@ private void registerStepClassBeanDefinition(BeanDefinitionRegistry registry, Cl
166178
.getBeanDefinition());
167179
}
168180

169-
public final void stop() {
181+
final void stop() {
170182
// Cucumber only supports 1 set of before/after semantics while JUnit
171183
// and Spring have 2 sets. So here we use a stack to ensure we don't
172184
// invoke only the matching after methods for each before methods.

cucumber-spring/src/test/java/io/cucumber/spring/TestTestContextAdaptorTest.java

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.springframework.test.context.TestContextManager;
2020
import org.springframework.test.context.TestExecutionListener;
2121

22+
import static io.cucumber.spring.TestContextAdaptor.create;
2223
import static java.util.Collections.singletonList;
2324
import static org.junit.jupiter.api.Assertions.assertAll;
2425
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -44,7 +45,7 @@ void verifyNoMoroInteractions() {
4445
@Test
4546
void invokesAllLiveCycleHooks() throws Exception {
4647
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
47-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
48+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
4849
manager.registerTestExecutionListeners(listener);
4950
InOrder inOrder = inOrder(listener);
5051

@@ -63,7 +64,7 @@ void invokesAllLiveCycleHooks() throws Exception {
6364
@Test
6465
void invokesAfterClassIfBeforeClassFailed() throws Exception {
6566
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
66-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
67+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
6768
manager.registerTestExecutionListeners(listener);
6869
InOrder inOrder = inOrder(listener);
6970

@@ -79,7 +80,7 @@ void invokesAfterClassIfBeforeClassFailed() throws Exception {
7980
@Test
8081
void invokesAfterClassIfPrepareTestInstanceFailed() throws Exception {
8182
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
82-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
83+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
8384
manager.registerTestExecutionListeners(listener);
8485
InOrder inOrder = inOrder(listener);
8586

@@ -95,7 +96,7 @@ void invokesAfterClassIfPrepareTestInstanceFailed() throws Exception {
9596
@Test
9697
void invokesAfterMethodIfBeforeMethodThrows() throws Exception {
9798
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
98-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
99+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
99100
manager.registerTestExecutionListeners(listener);
100101
InOrder inOrder = inOrder(listener);
101102

@@ -114,7 +115,7 @@ void invokesAfterMethodIfBeforeMethodThrows() throws Exception {
114115
@Test
115116
void invokesAfterTestExecutionIfBeforeTestExecutionThrows() throws Exception {
116117
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
117-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
118+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
118119
manager.registerTestExecutionListeners(listener);
119120
InOrder inOrder = inOrder(listener);
120121

@@ -134,7 +135,7 @@ void invokesAfterTestExecutionIfBeforeTestExecutionThrows() throws Exception {
134135
@Test
135136
void invokesAfterTestMethodIfAfterTestExecutionThrows() throws Exception {
136137
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
137-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
138+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
138139
manager.registerTestExecutionListeners(listener);
139140
InOrder inOrder = inOrder(listener);
140141

@@ -155,7 +156,7 @@ void invokesAfterTestMethodIfAfterTestExecutionThrows() throws Exception {
155156
@Test
156157
void invokesAfterTesClassIfAfterTestMethodThrows() throws Exception {
157158
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
158-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
159+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
159160
manager.registerTestExecutionListeners(listener);
160161
InOrder inOrder = inOrder(listener);
161162

@@ -176,7 +177,7 @@ void invokesAfterTesClassIfAfterTestMethodThrows() throws Exception {
176177
@Test
177178
void invokesAllMethodsPriorIfAfterTestClassThrows() throws Exception {
178179
TestContextManager manager = new TestContextManager(SomeContextConfiguration.class);
179-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(SomeContextConfiguration.class));
180+
TestContextAdaptor adaptor = create(() -> manager, singletonList(SomeContextConfiguration.class));
180181
manager.registerTestExecutionListeners(listener);
181182
InOrder inOrder = inOrder(listener);
182183

@@ -198,7 +199,7 @@ void invokesAllMethodsPriorIfAfterTestClassThrows() throws Exception {
198199
@ValueSource(classes = { WithAutowiredDependency.class, WithConstructorDependency.class })
199200
void autowireAndPostProcessesOnlyOnce(Class<? extends Spy> testClass) {
200201
TestContextManager manager = new TestContextManager(testClass);
201-
TestContextAdaptor adaptor = new TestContextAdaptor(manager, singletonList(testClass));
202+
TestContextAdaptor adaptor = create(() -> manager, singletonList(testClass));
202203

203204
assertAll(
204205
() -> assertDoesNotThrow(adaptor::start),

0 commit comments

Comments
 (0)