Skip to content

Commit a10a8e5

Browse files
committed
Support concurrent execution in TestContextManager & DefaultTestContext
Prior to this commit, executing tests concurrently in the TestContext Framework (TCF) was unsupported and typically lead to unpredictable results. This commit addresses this core issue by supporting concurrent execution in the TestContextManager and the DefaultTestContext. Specifically, the TestContextManager now uses ThreadLocal storage for the current TestContext, thereby ensuring that any registered TestExecutionListeners and the TestContextManager itself operate on a TestContext specific to the current thread. In order to avoid repeatedly incurring the costs of the overhead of the TCF bootstrapping process, the original TestContext built by the TestContextBootstrapper is used as a template which is then passed to the copy constructor of the concrete implementation of the TestContext to create the context for the current thread. DefaultTestContext now implements such a copy constructor, and all concrete implementations of TestContext are encouraged to do the same. If the TestContext built by the TestContextBootstrapper does not provide a copy constructor, thread-safety and support for concurrency are left completely to the implementation of the concrete TestContext. Note, however, that this commit does not address any thread-safety or concurrency issues in the ContextLoader SPI or its implementations. Issue: SPR-5863
1 parent ec7aefa commit a10a8e5

File tree

4 files changed

+201
-2
lines changed

4 files changed

+201
-2
lines changed

spring-test/src/main/java/org/springframework/test/context/TestContext.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2015 the original author or authors.
2+
* Copyright 2002-2016 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -27,6 +27,14 @@
2727
* {@code TestContext} encapsulates the context in which a test is executed,
2828
* agnostic of the actual testing framework in use.
2929
*
30+
* <p>As of Spring Framework 5.0, concrete implementations are highly encouraged
31+
* to implement a <em>copy constructor</em> in order to allow the immutable state
32+
* and attributes of a {@code TestContext} to be used as a template for additional
33+
* contexts created for parallel test execution. The copy constructor must accept a
34+
* single argument of the type of the concrete implementation. Any implementation
35+
* that does not provide a copy constructor will likely fail in an environment
36+
* that executes tests concurrently.
37+
*
3038
* @author Sam Brannen
3139
* @since 2.5
3240
*/

spring-test/src/main/java/org/springframework/test/context/TestContextManager.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.test.context;
1818

19+
import java.lang.reflect.Constructor;
1920
import java.lang.reflect.Method;
2021
import java.util.ArrayList;
2122
import java.util.Collections;
@@ -25,6 +26,7 @@
2526
import org.apache.commons.logging.LogFactory;
2627

2728
import org.springframework.util.Assert;
29+
import org.springframework.util.ClassUtils;
2830
import org.springframework.util.ReflectionUtils;
2931

3032
/**
@@ -92,6 +94,13 @@ public class TestContextManager {
9294

9395
private final TestContext testContext;
9496

97+
private final ThreadLocal<TestContext> testContextHolder = new ThreadLocal<TestContext>() {
98+
99+
protected TestContext initialValue() {
100+
return copyTestContext(TestContextManager.this.testContext);
101+
}
102+
};
103+
95104
private final List<TestExecutionListener> testExecutionListeners = new ArrayList<>();
96105

97106

@@ -131,7 +140,7 @@ public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
131140
* Get the {@link TestContext} managed by this {@code TestContextManager}.
132141
*/
133142
public final TestContext getTestContext() {
134-
return this.testContext;
143+
return this.testContextHolder.get();
135144
}
136145

137146
/**
@@ -482,6 +491,9 @@ public void afterTestClass() throws Exception {
482491
}
483492
}
484493
}
494+
495+
this.testContextHolder.remove();
496+
485497
if (afterTestClassException != null) {
486498
ReflectionUtils.rethrowException(afterTestClassException);
487499
}
@@ -529,4 +541,31 @@ private void logException(Throwable ex, String callbackName, TestExecutionListen
529541
}
530542
}
531543

544+
545+
/**
546+
* Attempt to create a copy of the supplied {@code TestContext} using its
547+
* <em>copy constructor</em>.
548+
*/
549+
private static TestContext copyTestContext(TestContext testContext) {
550+
Constructor<? extends TestContext> constructor = ClassUtils.getConstructorIfAvailable(testContext.getClass(),
551+
testContext.getClass());
552+
553+
if (constructor != null) {
554+
try {
555+
ReflectionUtils.makeAccessible(constructor);
556+
return constructor.newInstance(testContext);
557+
}
558+
catch (Exception ex) {
559+
if (logger.isInfoEnabled()) {
560+
logger.info(String.format("Failed to invoke copy constructor for [%s]; " +
561+
"concurrent test execution is therefore likely not supported.",
562+
testContext), ex);
563+
}
564+
}
565+
}
566+
567+
// fallback to original instance
568+
return testContext;
569+
}
570+
532571
}

spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ public class DefaultTestContext implements TestContext {
5656
private volatile Throwable testException;
5757

5858

59+
/**
60+
* <em>Copy constructor</em> for creating a new {@code DefaultTestContext}
61+
* based on the immutable state and <em>attributes</em> of the supplied context.
62+
*
63+
* <p><em>Immutable state</em> includes all arguments supplied to
64+
* {@link #DefaultTestContext(Class, MergedContextConfiguration, CacheAwareContextLoaderDelegate)}.
65+
*/
66+
public DefaultTestContext(DefaultTestContext testContext) {
67+
this(testContext.testClass, testContext.mergedContextConfiguration,
68+
testContext.cacheAwareContextLoaderDelegate);
69+
testContext.attributes.forEach(this.attributes::put);
70+
}
71+
5972
/**
6073
* Construct a new {@code DefaultTestContext} from the supplied arguments.
6174
* @param testClass the test class for this test context; never {@code null}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright 2002-2016 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.test.context;
18+
19+
import java.lang.reflect.Method;
20+
import java.util.Collections;
21+
import java.util.Set;
22+
import java.util.TreeSet;
23+
import java.util.stream.IntStream;
24+
25+
import org.junit.Test;
26+
27+
import static java.util.Arrays.stream;
28+
import static java.util.stream.Collectors.toCollection;
29+
import static org.hamcrest.CoreMatchers.equalTo;
30+
import static org.junit.Assert.assertEquals;
31+
import static org.junit.Assert.assertThat;
32+
33+
/**
34+
* Integration tests that verify proper concurrency support between a
35+
* {@link TestContextManager} and the {@link TestContext} it manages
36+
* when a registered {@link TestExecutionListener} updates the mutable
37+
* state and attributes of the context from concurrently executing threads.
38+
*
39+
* <p>In other words, these tests verify that mutated state and attributes
40+
* are only be visible to the thread in which the mutation occurred.
41+
*
42+
* @author Sam Brannen
43+
* @since 5.0
44+
*/
45+
public class TestContextConcurrencyTests {
46+
47+
private static Set<String> expectedMethods = stream(TestCase.class.getDeclaredMethods()).map(
48+
Method::getName).collect(toCollection(TreeSet::new));
49+
50+
private static final Set<String> actualMethods = Collections.synchronizedSet(new TreeSet<>());
51+
52+
private static final TestCase testInstance = new TestCase();
53+
54+
55+
@Test
56+
public void invokeTestContextManagerFromConcurrentThreads() {
57+
TestContextManager tcm = new TestContextManager(TestCase.class);
58+
59+
// Run the actual test several times in order to increase the chance of threads
60+
// stepping on each others' toes by overwriting the same mutable state in the
61+
// TestContext.
62+
IntStream.range(1, 20).forEach(i -> {
63+
actualMethods.clear();
64+
// Execute TestExecutionListener in parallel, thereby simulating parallel
65+
// test method execution.
66+
stream(TestCase.class.getDeclaredMethods()).parallel().forEach(testMethod -> {
67+
try {
68+
tcm.beforeTestClass();
69+
tcm.beforeTestMethod(testInstance, testMethod);
70+
// no need to invoke the actual test method
71+
tcm.afterTestMethod(testInstance, testMethod, null);
72+
tcm.afterTestClass();
73+
}
74+
catch (Exception ex) {
75+
throw new RuntimeException(ex);
76+
}
77+
});
78+
assertThat(actualMethods, equalTo(expectedMethods));
79+
});
80+
assertEquals(0, tcm.getTestContext().attributeNames().length);
81+
}
82+
83+
84+
@TestExecutionListeners(TrackingListener.class)
85+
@SuppressWarnings("unused")
86+
private static class TestCase {
87+
88+
void test_001() {
89+
}
90+
91+
void test_002() {
92+
}
93+
94+
void test_003() {
95+
}
96+
97+
void test_004() {
98+
}
99+
100+
void test_005() {
101+
}
102+
103+
void test_006() {
104+
}
105+
106+
void test_007() {
107+
}
108+
109+
void test_008() {
110+
}
111+
112+
void test_009() {
113+
}
114+
115+
void test_010() {
116+
}
117+
}
118+
119+
private static class TrackingListener implements TestExecutionListener {
120+
121+
private ThreadLocal<String> methodName = new ThreadLocal<>();
122+
123+
124+
@Override
125+
public void beforeTestMethod(TestContext testContext) throws Exception {
126+
String name = testContext.getTestMethod().getName();
127+
actualMethods.add(name);
128+
testContext.setAttribute("method", name);
129+
this.methodName.set(name);
130+
}
131+
132+
@Override
133+
public void afterTestMethod(TestContext testContext) throws Exception {
134+
assertEquals(this.methodName.get(), testContext.getAttribute("method"));
135+
}
136+
137+
}
138+
139+
}

0 commit comments

Comments
 (0)