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
This commit is contained in:
Sam Brannen 2016-09-02 20:28:02 +02:00
parent ec7aefa858
commit a10a8e56df
4 changed files with 201 additions and 2 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2015 the original author or authors.
* Copyright 2002-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -27,6 +27,14 @@ import org.springframework.test.annotation.DirtiesContext.HierarchyMode;
* {@code TestContext} encapsulates the context in which a test is executed,
* agnostic of the actual testing framework in use.
*
* <p>As of Spring Framework 5.0, concrete implementations are highly encouraged
* to implement a <em>copy constructor</em> in order to allow the immutable state
* and attributes of a {@code TestContext} to be used as a template for additional
* contexts created for parallel test execution. The copy constructor must accept a
* single argument of the type of the concrete implementation. Any implementation
* that does not provide a copy constructor will likely fail in an environment
* that executes tests concurrently.
*
* @author Sam Brannen
* @since 2.5
*/

View File

@ -16,6 +16,7 @@
package org.springframework.test.context;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
@ -25,6 +26,7 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
/**
@ -92,6 +94,13 @@ public class TestContextManager {
private final TestContext testContext;
private final ThreadLocal<TestContext> testContextHolder = new ThreadLocal<TestContext>() {
protected TestContext initialValue() {
return copyTestContext(TestContextManager.this.testContext);
}
};
private final List<TestExecutionListener> testExecutionListeners = new ArrayList<>();
@ -131,7 +140,7 @@ public class TestContextManager {
* Get the {@link TestContext} managed by this {@code TestContextManager}.
*/
public final TestContext getTestContext() {
return this.testContext;
return this.testContextHolder.get();
}
/**
@ -482,6 +491,9 @@ public class TestContextManager {
}
}
}
this.testContextHolder.remove();
if (afterTestClassException != null) {
ReflectionUtils.rethrowException(afterTestClassException);
}
@ -529,4 +541,31 @@ public class TestContextManager {
}
}
/**
* Attempt to create a copy of the supplied {@code TestContext} using its
* <em>copy constructor</em>.
*/
private static TestContext copyTestContext(TestContext testContext) {
Constructor<? extends TestContext> constructor = ClassUtils.getConstructorIfAvailable(testContext.getClass(),
testContext.getClass());
if (constructor != null) {
try {
ReflectionUtils.makeAccessible(constructor);
return constructor.newInstance(testContext);
}
catch (Exception ex) {
if (logger.isInfoEnabled()) {
logger.info(String.format("Failed to invoke copy constructor for [%s]; " +
"concurrent test execution is therefore likely not supported.",
testContext), ex);
}
}
}
// fallback to original instance
return testContext;
}
}

View File

@ -56,6 +56,19 @@ public class DefaultTestContext implements TestContext {
private volatile Throwable testException;
/**
* <em>Copy constructor</em> for creating a new {@code DefaultTestContext}
* based on the immutable state and <em>attributes</em> of the supplied context.
*
* <p><em>Immutable state</em> includes all arguments supplied to
* {@link #DefaultTestContext(Class, MergedContextConfiguration, CacheAwareContextLoaderDelegate)}.
*/
public DefaultTestContext(DefaultTestContext testContext) {
this(testContext.testClass, testContext.mergedContextConfiguration,
testContext.cacheAwareContextLoaderDelegate);
testContext.attributes.forEach(this.attributes::put);
}
/**
* Construct a new {@code DefaultTestContext} from the supplied arguments.
* @param testClass the test class for this test context; never {@code null}

View File

@ -0,0 +1,139 @@
/*
* Copyright 2002-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.context;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.IntStream;
import org.junit.Test;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toCollection;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
/**
* Integration tests that verify proper concurrency support between a
* {@link TestContextManager} and the {@link TestContext} it manages
* when a registered {@link TestExecutionListener} updates the mutable
* state and attributes of the context from concurrently executing threads.
*
* <p>In other words, these tests verify that mutated state and attributes
* are only be visible to the thread in which the mutation occurred.
*
* @author Sam Brannen
* @since 5.0
*/
public class TestContextConcurrencyTests {
private static Set<String> expectedMethods = stream(TestCase.class.getDeclaredMethods()).map(
Method::getName).collect(toCollection(TreeSet::new));
private static final Set<String> actualMethods = Collections.synchronizedSet(new TreeSet<>());
private static final TestCase testInstance = new TestCase();
@Test
public void invokeTestContextManagerFromConcurrentThreads() {
TestContextManager tcm = new TestContextManager(TestCase.class);
// Run the actual test several times in order to increase the chance of threads
// stepping on each others' toes by overwriting the same mutable state in the
// TestContext.
IntStream.range(1, 20).forEach(i -> {
actualMethods.clear();
// Execute TestExecutionListener in parallel, thereby simulating parallel
// test method execution.
stream(TestCase.class.getDeclaredMethods()).parallel().forEach(testMethod -> {
try {
tcm.beforeTestClass();
tcm.beforeTestMethod(testInstance, testMethod);
// no need to invoke the actual test method
tcm.afterTestMethod(testInstance, testMethod, null);
tcm.afterTestClass();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
});
assertThat(actualMethods, equalTo(expectedMethods));
});
assertEquals(0, tcm.getTestContext().attributeNames().length);
}
@TestExecutionListeners(TrackingListener.class)
@SuppressWarnings("unused")
private static class TestCase {
void test_001() {
}
void test_002() {
}
void test_003() {
}
void test_004() {
}
void test_005() {
}
void test_006() {
}
void test_007() {
}
void test_008() {
}
void test_009() {
}
void test_010() {
}
}
private static class TrackingListener implements TestExecutionListener {
private ThreadLocal<String> methodName = new ThreadLocal<>();
@Override
public void beforeTestMethod(TestContext testContext) throws Exception {
String name = testContext.getTestMethod().getName();
actualMethods.add(name);
testContext.setAttribute("method", name);
this.methodName.set(name);
}
@Override
public void afterTestMethod(TestContext testContext) throws Exception {
assertEquals(this.methodName.get(), testContext.getAttribute("method"));
}
}
}