Skip to content

Commit 2a4c48c

Browse files
dreis2211philwebb
authored andcommitted
Add JUnit 5 ModifiedClassPathExtension
Add a JUnit 5 extension that allows tests to be run with a modified classpath. Since JUnit 5 does not currently offer a way to run tests with a different classpath, we instead fake the original invocation and launch an entirely new run for each method. See gh-17491
1 parent 90d824f commit 2a4c48c

File tree

6 files changed

+250
-4
lines changed

6 files changed

+250
-4
lines changed

spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@
7070
<groupId>org.junit.jupiter</groupId>
7171
<artifactId>junit-jupiter</artifactId>
7272
</dependency>
73+
<dependency>
74+
<groupId>org.junit.platform</groupId>
75+
<artifactId>junit-platform-launcher</artifactId>
76+
</dependency>
7377
<dependency>
7478
<groupId>org.mockito</groupId>
7579
<artifactId>mockito-core</artifactId>

spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
import java.lang.annotation.Target;
2525

2626
/**
27-
* Annotation used in combination with {@link ModifiedClassPathRunner} to exclude entries
28-
* from the classpath.
27+
* Annotation used in combination with {@link ModifiedClassPathRunner} or
28+
* {@link ModifiedClassPathExtension} to exclude entries from the classpath.
2929
*
3030
* @author Andy Wilkinson
3131
* @since 1.5.0

spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
import java.lang.annotation.Target;
2424

2525
/**
26-
* Annotation used in combination with {@link ModifiedClassPathRunner} to override entries
27-
* on the classpath.
26+
* Annotation used in combination with {@link ModifiedClassPathRunner} or
27+
* {@link ModifiedClassPathExtension} to override entries on the classpath.
2828
*
2929
* @author Andy Wilkinson
3030
* @since 1.5.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright 2012-2019 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+
* https://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.boot.testsupport.runner.classpath;
18+
19+
import java.lang.reflect.Field;
20+
import java.lang.reflect.Method;
21+
import java.net.URLClassLoader;
22+
import java.util.concurrent.atomic.AtomicBoolean;
23+
24+
import org.junit.jupiter.api.extension.Extension;
25+
import org.junit.jupiter.api.extension.ExtensionContext;
26+
import org.junit.jupiter.api.extension.InvocationInterceptor;
27+
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
28+
import org.junit.platform.engine.discovery.DiscoverySelectors;
29+
import org.junit.platform.launcher.Launcher;
30+
import org.junit.platform.launcher.LauncherDiscoveryRequest;
31+
import org.junit.platform.launcher.TestPlan;
32+
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
33+
import org.junit.platform.launcher.core.LauncherFactory;
34+
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
35+
import org.junit.platform.launcher.listeners.TestExecutionSummary;
36+
37+
import org.springframework.util.CollectionUtils;
38+
import org.springframework.util.ReflectionUtils;
39+
40+
/**
41+
* A custom {@link Extension} that runs tests using a modified class path. Entries are
42+
* excluded from the class path using {@link ClassPathExclusions @ClassPathExclusions} and
43+
* overridden using {@link ClassPathOverrides @ClassPathOverrides} on the test class. A
44+
* class loader is created with the customized class path and is used both to load the
45+
* test class and as the thread context class loader while the test is being run.
46+
*
47+
* @author Christoph Dreis
48+
* @since 2.2.0
49+
*/
50+
public class ModifiedClassPathExtension implements InvocationInterceptor {
51+
52+
@Override
53+
public void interceptBeforeAllMethod(Invocation<Void> invocation,
54+
ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
55+
interceptInvocation(invocation, extensionContext);
56+
}
57+
58+
@Override
59+
public void interceptBeforeEachMethod(Invocation<Void> invocation,
60+
ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
61+
interceptInvocation(invocation, extensionContext);
62+
}
63+
64+
@Override
65+
public void interceptAfterEachMethod(Invocation<Void> invocation,
66+
ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
67+
interceptInvocation(invocation, extensionContext);
68+
}
69+
70+
@Override
71+
public void interceptAfterAllMethod(Invocation<Void> invocation,
72+
ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
73+
interceptInvocation(invocation, extensionContext);
74+
}
75+
76+
@Override
77+
public void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
78+
ExtensionContext extensionContext) throws Throwable {
79+
if (isModifiedClassPathClassLoader(extensionContext)) {
80+
invocation.proceed();
81+
return;
82+
}
83+
ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
84+
URLClassLoader classLoader = ModifiedClassPathClassLoaderFactory
85+
.createTestClassLoader(extensionContext.getRequiredTestClass());
86+
Thread.currentThread().setContextClassLoader(classLoader);
87+
try {
88+
fakeInvocation(invocation);
89+
TestExecutionSummary summary = launchTests(invocationContext, extensionContext, classLoader);
90+
if (!CollectionUtils.isEmpty(summary.getFailures())) {
91+
throw summary.getFailures().get(0).getException();
92+
}
93+
}
94+
catch (Exception ex) {
95+
throw ex;
96+
}
97+
finally {
98+
Thread.currentThread().setContextClassLoader(originalClassLoader);
99+
}
100+
}
101+
102+
private TestExecutionSummary launchTests(ReflectiveInvocationContext<Method> invocationContext,
103+
ExtensionContext extensionContext, URLClassLoader classLoader) throws ClassNotFoundException {
104+
Class<?> testClass = classLoader.loadClass(extensionContext.getRequiredTestClass().getName());
105+
Method method = ReflectionUtils.findMethod(testClass, invocationContext.getExecutable().getName());
106+
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
107+
.selectors(DiscoverySelectors.selectMethod(testClass, method)).build();
108+
Launcher launcher = LauncherFactory.create();
109+
TestPlan testPlan = launcher.discover(request);
110+
SummaryGeneratingListener listener = new SummaryGeneratingListener();
111+
launcher.registerTestExecutionListeners(listener);
112+
launcher.execute(testPlan);
113+
return listener.getSummary();
114+
}
115+
116+
private boolean isModifiedClassPathClassLoader(ExtensionContext extensionContext) {
117+
return extensionContext.getRequiredTestClass().getClassLoader().getClass().getName()
118+
.equals(ModifiedClassPathClassLoader.class.getName());
119+
}
120+
121+
private void interceptInvocation(Invocation<Void> invocation, ExtensionContext extensionContext) throws Throwable {
122+
if (isModifiedClassPathClassLoader(extensionContext)) {
123+
invocation.proceed();
124+
}
125+
else {
126+
fakeInvocation(invocation);
127+
}
128+
}
129+
130+
private void fakeInvocation(Invocation invocation) {
131+
try {
132+
Field field = ReflectionUtils.findField(invocation.getClass(), "invoked");
133+
ReflectionUtils.makeAccessible(field);
134+
ReflectionUtils.setField(field, invocation, new AtomicBoolean(true));
135+
}
136+
catch (Throwable ignore) {
137+
138+
}
139+
}
140+
141+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2012-2019 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+
* https://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.boot.testsupport.runner.classpath;
18+
19+
import org.hamcrest.Matcher;
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.extension.ExtendWith;
22+
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
import static org.hamcrest.Matchers.isA;
25+
26+
/**
27+
* Tests for {@link ModifiedClassPathExtension} excluding entries from the class path.
28+
*
29+
* @author Christoph Dreis
30+
*/
31+
@ExtendWith(ModifiedClassPathExtension.class)
32+
@ClassPathExclusions("hibernate-validator-*.jar")
33+
class ModifiedClassPathExtensionExclusionsTests {
34+
35+
private static final String EXCLUDED_RESOURCE = "META-INF/services/" + "javax.validation.spi.ValidationProvider";
36+
37+
@Test
38+
void entriesAreFilteredFromTestClassClassLoader() {
39+
assertThat(getClass().getClassLoader().getResource(EXCLUDED_RESOURCE)).isNull();
40+
}
41+
42+
@Test
43+
void entriesAreFilteredFromThreadContextClassLoader() {
44+
assertThat(Thread.currentThread().getContextClassLoader().getResource(EXCLUDED_RESOURCE)).isNull();
45+
}
46+
47+
@Test
48+
void testsThatUseHamcrestWorkCorrectly() {
49+
Matcher<IllegalStateException> matcher = isA(IllegalStateException.class);
50+
assertThat(matcher.matches(new IllegalStateException())).isTrue();
51+
}
52+
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2012-2019 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+
* https://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.boot.testsupport.runner.classpath;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.junit.jupiter.api.extension.ExtendWith;
21+
22+
import org.springframework.context.ApplicationContext;
23+
import org.springframework.util.StringUtils;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
27+
/**
28+
* Tests for {@link ModifiedClassPathExtension} overriding entries on the class path.
29+
*
30+
* @author Christoph Dreis
31+
*/
32+
@ExtendWith(ModifiedClassPathExtension.class)
33+
@ClassPathOverrides("org.springframework:spring-context:4.1.0.RELEASE")
34+
class ModifiedClassPathExtensionOverridesTests {
35+
36+
@Test
37+
void classesAreLoadedFromOverride() {
38+
assertThat(ApplicationContext.class.getProtectionDomain().getCodeSource().getLocation().toString())
39+
.endsWith("spring-context-4.1.0.RELEASE.jar");
40+
}
41+
42+
@Test
43+
void classesAreLoadedFromTransitiveDependencyOfOverride() {
44+
assertThat(StringUtils.class.getProtectionDomain().getCodeSource().getLocation().toString())
45+
.endsWith("spring-core-4.1.0.RELEASE.jar");
46+
}
47+
48+
}

0 commit comments

Comments
 (0)