diff --git a/spring-test/src/main/java/org/springframework/test/context/TestContext.java b/spring-test/src/main/java/org/springframework/test/context/TestContext.java index cd8df7ab5ce..01d9c856585 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestContext.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestContext.java @@ -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. * + *

As of Spring Framework 5.0, concrete implementations are highly encouraged + * to implement a copy constructor 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 */ diff --git a/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java b/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java index 264b94eb42c..1d625d4a935 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java @@ -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 testContextHolder = new ThreadLocal() { + + protected TestContext initialValue() { + return copyTestContext(TestContextManager.this.testContext); + } + }; + private final List 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 + * copy constructor. + */ + private static TestContext copyTestContext(TestContext testContext) { + Constructor 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; + } + } diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java b/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java index cdd761be681..c03a79e420a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java @@ -56,6 +56,19 @@ public class DefaultTestContext implements TestContext { private volatile Throwable testException; + /** + * Copy constructor for creating a new {@code DefaultTestContext} + * based on the immutable state and attributes of the supplied context. + * + *

Immutable state 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} diff --git a/spring-test/src/test/java/org/springframework/test/context/TestContextConcurrencyTests.java b/spring-test/src/test/java/org/springframework/test/context/TestContextConcurrencyTests.java new file mode 100644 index 00000000000..6a847232a46 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/TestContextConcurrencyTests.java @@ -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. + * + *

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 expectedMethods = stream(TestCase.class.getDeclaredMethods()).map( + Method::getName).collect(toCollection(TreeSet::new)); + + private static final Set 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 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")); + } + + } + +}