diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml index 4d7e027f91..72f28e1b1a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml @@ -70,6 +70,10 @@ org.junit.jupiter junit-jupiter + + org.junit.platform + junit-platform-launcher + org.mockito mockito-core diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java index 249d0eb252..8c4f501395 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java @@ -24,8 +24,8 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Annotation used in combination with {@link ModifiedClassPathRunner} to exclude entries - * from the classpath. + * Annotation used in combination with {@link ModifiedClassPathRunner} or + * {@link ModifiedClassPathExtension} to exclude entries from the classpath. * * @author Andy Wilkinson * @since 1.5.0 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java index 75fc689c61..bc3fe9aaf9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java @@ -23,8 +23,8 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Annotation used in combination with {@link ModifiedClassPathRunner} to override entries - * on the classpath. + * Annotation used in combination with {@link ModifiedClassPathRunner} or + * {@link ModifiedClassPathExtension} to override entries on the classpath. * * @author Andy Wilkinson * @since 1.5.0 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtension.java new file mode 100644 index 0000000000..88c1b53640 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtension.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2019 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 + * + * https://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.boot.testsupport.runner.classpath; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URLClassLoader; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.TestPlan; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.launcher.listeners.TestExecutionSummary; + +import org.springframework.util.CollectionUtils; +import org.springframework.util.ReflectionUtils; + +/** + * A custom {@link Extension} that runs tests using a modified class path. Entries are + * excluded from the class path using {@link ClassPathExclusions @ClassPathExclusions} and + * overridden using {@link ClassPathOverrides @ClassPathOverrides} on the test class. A + * class loader is created with the customized class path and is used both to load the + * test class and as the thread context class loader while the test is being run. + * + * @author Christoph Dreis + * @since 2.2.0 + */ +public class ModifiedClassPathExtension implements InvocationInterceptor { + + @Override + public void interceptBeforeAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptInvocation(invocation, extensionContext); + } + + @Override + public void interceptBeforeEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptInvocation(invocation, extensionContext); + } + + @Override + public void interceptAfterEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptInvocation(invocation, extensionContext); + } + + @Override + public void interceptAfterAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptInvocation(invocation, extensionContext); + } + + @Override + public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + return; + } + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + URLClassLoader classLoader = ModifiedClassPathClassLoaderFactory + .createTestClassLoader(extensionContext.getRequiredTestClass()); + Thread.currentThread().setContextClassLoader(classLoader); + try { + fakeInvocation(invocation); + TestExecutionSummary summary = launchTests(invocationContext, extensionContext, classLoader); + if (!CollectionUtils.isEmpty(summary.getFailures())) { + throw summary.getFailures().get(0).getException(); + } + } + catch (Exception ex) { + throw ex; + } + finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + private TestExecutionSummary launchTests(ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext, URLClassLoader classLoader) throws ClassNotFoundException { + Class testClass = classLoader.loadClass(extensionContext.getRequiredTestClass().getName()); + Method method = ReflectionUtils.findMethod(testClass, invocationContext.getExecutable().getName()); + LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectMethod(testClass, method)).build(); + Launcher launcher = LauncherFactory.create(); + TestPlan testPlan = launcher.discover(request); + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + launcher.registerTestExecutionListeners(listener); + launcher.execute(testPlan); + return listener.getSummary(); + } + + private boolean isModifiedClassPathClassLoader(ExtensionContext extensionContext) { + return extensionContext.getRequiredTestClass().getClassLoader().getClass().getName() + .equals(ModifiedClassPathClassLoader.class.getName()); + } + + private void interceptInvocation(Invocation invocation, ExtensionContext extensionContext) throws Throwable { + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + } + else { + fakeInvocation(invocation); + } + } + + private void fakeInvocation(Invocation invocation) { + try { + Field field = ReflectionUtils.findField(invocation.getClass(), "invoked"); + ReflectionUtils.makeAccessible(field); + ReflectionUtils.setField(field, invocation, new AtomicBoolean(true)); + } + catch (Throwable ignore) { + + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionExclusionsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionExclusionsTests.java new file mode 100644 index 0000000000..a6fdcf1c3b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionExclusionsTests.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-2019 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 + * + * https://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.boot.testsupport.runner.classpath; + +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.isA; + +/** + * Tests for {@link ModifiedClassPathExtension} excluding entries from the class path. + * + * @author Christoph Dreis + */ +@ExtendWith(ModifiedClassPathExtension.class) +@ClassPathExclusions("hibernate-validator-*.jar") +class ModifiedClassPathExtensionExclusionsTests { + + private static final String EXCLUDED_RESOURCE = "META-INF/services/" + "javax.validation.spi.ValidationProvider"; + + @Test + void entriesAreFilteredFromTestClassClassLoader() { + assertThat(getClass().getClassLoader().getResource(EXCLUDED_RESOURCE)).isNull(); + } + + @Test + void entriesAreFilteredFromThreadContextClassLoader() { + assertThat(Thread.currentThread().getContextClassLoader().getResource(EXCLUDED_RESOURCE)).isNull(); + } + + @Test + void testsThatUseHamcrestWorkCorrectly() { + Matcher matcher = isA(IllegalStateException.class); + assertThat(matcher.matches(new IllegalStateException())).isTrue(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionOverridesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionOverridesTests.java new file mode 100644 index 0000000000..0dda8cca68 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionOverridesTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2019 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 + * + * https://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.boot.testsupport.runner.classpath; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.ApplicationContext; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ModifiedClassPathExtension} overriding entries on the class path. + * + * @author Christoph Dreis + */ +@ExtendWith(ModifiedClassPathExtension.class) +@ClassPathOverrides("org.springframework:spring-context:4.1.0.RELEASE") +class ModifiedClassPathExtensionOverridesTests { + + @Test + void classesAreLoadedFromOverride() { + assertThat(ApplicationContext.class.getProtectionDomain().getCodeSource().getLocation().toString()) + .endsWith("spring-context-4.1.0.RELEASE.jar"); + } + + @Test + void classesAreLoadedFromTransitiveDependencyOfOverride() { + assertThat(StringUtils.class.getProtectionDomain().getCodeSource().getLocation().toString()) + .endsWith("spring-core-4.1.0.RELEASE.jar"); + } + +}