Support modified classpath on methods and parameterized tests

Update `ModifiedClassPathExtension` and related classes so that
annotations can be used directly on test methods, or on classes
passed into parameterized tests.

Closes gh-33014
This commit is contained in:
Phillip Webb 2022-11-05 10:52:17 -07:00
parent d00e004622
commit d4cc8fc3a6
6 changed files with 156 additions and 37 deletions

View File

@ -32,7 +32,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
* @since 1.5.0 * @since 1.5.0
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) @Target({ ElementType.TYPE, ElementType.METHOD })
@Documented @Documented
@ExtendWith(ModifiedClassPathExtension.class) @ExtendWith(ModifiedClassPathExtension.class)
public @interface ClassPathExclusions { public @interface ClassPathExclusions {

View File

@ -31,7 +31,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
* @since 1.5.0 * @since 1.5.0
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) @Target({ ElementType.TYPE, ElementType.METHOD })
@Documented @Documented
@ExtendWith(ModifiedClassPathExtension.class) @ExtendWith(ModifiedClassPathExtension.class)
public @interface ClassPathOverrides { public @interface ClassPathOverrides {

View File

@ -25,15 +25,15 @@ import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
/** /**
* Annotation used to fork the classpath. This can be helpful where neither * Annotation used to fork the classpath. This can be helpful when using annotations on
* {@link ClassPathExclusions} or {@link ClassPathOverrides} are needed, but just a copy * parameterized tests, or where neither {@link ClassPathExclusions} or
* of the classpath. * {@link ClassPathOverrides} are needed, but just a copy of the classpath.
* *
* @author Christoph Dreis * @author Christoph Dreis
* @since 2.4.0 * @since 2.4.0
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) @Target({ ElementType.TYPE, ElementType.METHOD })
@Documented @Documented
@ExtendWith(ModifiedClassPathExtension.class) @ExtendWith(ModifiedClassPathExtension.class)
public @interface ForkedClassPath { public @interface ForkedClassPath {

View File

@ -18,17 +18,22 @@ package org.springframework.boot.testsupport.classpath;
import java.io.File; import java.io.File;
import java.lang.management.ManagementFactory; import java.lang.management.ManagementFactory;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.net.URLClassLoader; import java.net.URLClassLoader;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.jar.Attributes; import java.util.jar.Attributes;
import java.util.jar.JarFile; import java.util.jar.JarFile;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.apache.maven.repository.internal.MavenRepositorySystemUtils; import org.apache.maven.repository.internal.MavenRepositorySystemUtils;
@ -52,6 +57,7 @@ import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.util.AntPathMatcher; import org.springframework.util.AntPathMatcher;
import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
/** /**
@ -62,7 +68,7 @@ import org.springframework.util.StringUtils;
*/ */
final class ModifiedClassPathClassLoader extends URLClassLoader { final class ModifiedClassPathClassLoader extends URLClassLoader {
private static final Map<Class<?>, ModifiedClassPathClassLoader> cache = new ConcurrentReferenceHashMap<>(); private static final Map<List<AnnotatedElement>, ModifiedClassPathClassLoader> cache = new ConcurrentReferenceHashMap<>();
private static final Pattern INTELLIJ_CLASSPATH_JAR_PATTERN = Pattern.compile(".*classpath(\\d+)?\\.jar"); private static final Pattern INTELLIJ_CLASSPATH_JAR_PATTERN = Pattern.compile(".*classpath(\\d+)?\\.jar");
@ -84,19 +90,44 @@ final class ModifiedClassPathClassLoader extends URLClassLoader {
return super.loadClass(name); return super.loadClass(name);
} }
static ModifiedClassPathClassLoader get(Class<?> testClass) { static ModifiedClassPathClassLoader get(Class<?> testClass, Method testMethod, List<Object> arguments) {
return cache.computeIfAbsent(testClass, ModifiedClassPathClassLoader::compute); Set<AnnotatedElement> candidates = new LinkedHashSet<>();
candidates.add(testClass);
candidates.add(testMethod);
candidates.addAll(getAnnotatedElements(arguments.toArray()));
List<AnnotatedElement> annotatedElements = candidates.stream()
.filter(ModifiedClassPathClassLoader::hasAnnotation).collect(Collectors.toList());
if (annotatedElements.isEmpty()) {
return null;
}
return cache.computeIfAbsent(annotatedElements, (key) -> compute(testClass.getClassLoader(), key));
} }
private static ModifiedClassPathClassLoader compute(Class<?> testClass) { private static Collection<AnnotatedElement> getAnnotatedElements(Object[] array) {
ClassLoader classLoader = testClass.getClassLoader(); Set<AnnotatedElement> result = new LinkedHashSet<>();
MergedAnnotations annotations = MergedAnnotations.from(testClass, for (Object item : array) {
MergedAnnotations.SearchStrategy.TYPE_HIERARCHY); if (item instanceof AnnotatedElement) {
if (annotations.isPresent(ForkedClassPath.class) && (annotations.isPresent(ClassPathOverrides.class) result.add((AnnotatedElement) item);
|| annotations.isPresent(ClassPathExclusions.class))) { }
throw new IllegalStateException("@ForkedClassPath is redundant in combination with either " else if (ObjectUtils.isArray(item)) {
+ "@ClassPathOverrides or @ClassPathExclusions"); result.addAll(getAnnotatedElements(ObjectUtils.toObjectArray(item)));
}
} }
return result;
}
private static boolean hasAnnotation(AnnotatedElement element) {
MergedAnnotations annotations = MergedAnnotations.from(element,
MergedAnnotations.SearchStrategy.TYPE_HIERARCHY);
return annotations.isPresent(ForkedClassPath.class) || annotations.isPresent(ClassPathOverrides.class)
|| annotations.isPresent(ClassPathExclusions.class);
}
private static ModifiedClassPathClassLoader compute(ClassLoader classLoader,
List<AnnotatedElement> annotatedClasses) {
List<MergedAnnotations> annotations = annotatedClasses.stream()
.map((source) -> MergedAnnotations.from(source, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY))
.collect(Collectors.toList());
return new ModifiedClassPathClassLoader(processUrls(extractUrls(classLoader), annotations), return new ModifiedClassPathClassLoader(processUrls(extractUrls(classLoader), annotations),
classLoader.getParent(), classLoader); classLoader.getParent(), classLoader);
} }
@ -174,9 +205,9 @@ final class ModifiedClassPathClassLoader extends URLClassLoader {
} }
} }
private static URL[] processUrls(URL[] urls, MergedAnnotations annotations) { private static URL[] processUrls(URL[] urls, List<MergedAnnotations> annotations) {
ClassPathEntryFilter filter = new ClassPathEntryFilter(annotations.get(ClassPathExclusions.class)); ClassPathEntryFilter filter = new ClassPathEntryFilter(annotations);
List<URL> additionalUrls = getAdditionalUrls(annotations.get(ClassPathOverrides.class)); List<URL> additionalUrls = getAdditionalUrls(annotations);
List<URL> processedUrls = new ArrayList<>(additionalUrls); List<URL> processedUrls = new ArrayList<>(additionalUrls);
for (URL url : urls) { for (URL url : urls) {
if (!filter.isExcluded(url)) { if (!filter.isExcluded(url)) {
@ -186,11 +217,15 @@ final class ModifiedClassPathClassLoader extends URLClassLoader {
return processedUrls.toArray(new URL[0]); return processedUrls.toArray(new URL[0]);
} }
private static List<URL> getAdditionalUrls(MergedAnnotation<ClassPathOverrides> annotation) { private static List<URL> getAdditionalUrls(List<MergedAnnotations> annotations) {
if (!annotation.isPresent()) { Set<URL> urls = new LinkedHashSet<>();
return Collections.emptyList(); for (MergedAnnotations candidate : annotations) {
MergedAnnotation<ClassPathOverrides> annotation = candidate.get(ClassPathOverrides.class);
if (annotation.isPresent()) {
urls.addAll(resolveCoordinates(annotation.getStringArray(MergedAnnotation.VALUE)));
}
} }
return resolveCoordinates(annotation.getStringArray(MergedAnnotation.VALUE)); return urls.stream().collect(Collectors.toList());
} }
private static List<URL> resolveCoordinates(String[] coordinates) { private static List<URL> resolveCoordinates(String[] coordinates) {
@ -241,9 +276,15 @@ final class ModifiedClassPathClassLoader extends URLClassLoader {
private final AntPathMatcher matcher = new AntPathMatcher(); private final AntPathMatcher matcher = new AntPathMatcher();
private ClassPathEntryFilter(MergedAnnotation<ClassPathExclusions> annotation) { private ClassPathEntryFilter(List<MergedAnnotations> annotations) {
this.exclusions = annotation.getValue(MergedAnnotation.VALUE, String[].class).map(Arrays::asList) Set<String> exclusions = new LinkedHashSet<>();
.orElse(Collections.emptyList()); for (MergedAnnotations candidate : annotations) {
MergedAnnotation<ClassPathExclusions> annotation = candidate.get(ClassPathExclusions.class);
if (annotation.isPresent()) {
exclusions.addAll(Arrays.asList(annotation.getStringArray(MergedAnnotation.VALUE)));
}
}
this.exclusions = exclusions.stream().collect(Collectors.toList());
} }
private boolean isExcluded(URL url) { private boolean isExcluded(URL url) {

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2012-2021 the original author or authors. * Copyright 2012-2022 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -46,7 +46,7 @@ import org.springframework.util.ReflectionUtils;
* *
* @author Christoph Dreis * @author Christoph Dreis
*/ */
class ModifiedClassPathExtension implements InvocationInterceptor { public class ModifiedClassPathExtension implements InvocationInterceptor {
@Override @Override
public void interceptBeforeAllMethod(Invocation<Void> invocation, public void interceptBeforeAllMethod(Invocation<Void> invocation,
@ -75,20 +75,31 @@ class ModifiedClassPathExtension implements InvocationInterceptor {
@Override @Override
public void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext, public void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
ExtensionContext extensionContext) throws Throwable { ExtensionContext extensionContext) throws Throwable {
interceptMethod(invocation, invocationContext, extensionContext);
}
@Override
public void interceptTestTemplateMethod(Invocation<Void> invocation,
ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
interceptMethod(invocation, invocationContext, extensionContext);
}
private void interceptMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
ExtensionContext extensionContext) throws Throwable {
if (isModifiedClassPathClassLoader(extensionContext)) { if (isModifiedClassPathClassLoader(extensionContext)) {
invocation.proceed(); invocation.proceed();
return; return;
} }
invocation.skip();
runTestWithModifiedClassPath(invocationContext, extensionContext);
}
private void runTestWithModifiedClassPath(ReflectiveInvocationContext<Method> invocationContext,
ExtensionContext extensionContext) throws Throwable {
Class<?> testClass = extensionContext.getRequiredTestClass(); Class<?> testClass = extensionContext.getRequiredTestClass();
Method testMethod = invocationContext.getExecutable(); Method testMethod = invocationContext.getExecutable();
URLClassLoader modifiedClassLoader = ModifiedClassPathClassLoader.get(testClass, testMethod,
invocationContext.getArguments());
if (modifiedClassLoader == null) {
invocation.proceed();
return;
}
invocation.skip();
ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
URLClassLoader modifiedClassLoader = ModifiedClassPathClassLoader.get(testClass);
Thread.currentThread().setContextClassLoader(modifiedClassLoader); Thread.currentThread().setContextClassLoader(modifiedClassLoader);
try { try {
runTest(modifiedClassLoader, testClass.getName(), testMethod.getName()); runTest(modifiedClassLoader, testClass.getName(), testMethod.getName());

View File

@ -0,0 +1,67 @@
/*
* 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.classpath;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.context.ApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ModifiedClassPathExtension} overriding entries on the class path via
* parameters.
*
* @author Phillip Webb
*/
class ModifiedClassPathExtensionOverridesParametrizedTests {
@ParameterizedTest
@ForkedClassPath
@MethodSource("parameter")
void classesAreLoadedFromParameter(Class<?> type) {
assertThat(ApplicationContext.class.getProtectionDomain().getCodeSource().getLocation().toString())
.endsWith("spring-context-4.1.0.RELEASE.jar");
}
static Class<?>[] parameter() {
return new Class<?>[] { ClassWithOverride.class };
}
@ParameterizedTest
@ForkedClassPath
@MethodSource("arrayParameter")
void classesAreLoadedFromParameterInArray(Object[] types) {
assertThat(ApplicationContext.class.getProtectionDomain().getCodeSource().getLocation().toString())
.endsWith("spring-context-4.1.0.RELEASE.jar");
}
static Stream<Arguments> arrayParameter() {
Object[] types = new Object[] { ClassWithOverride.class };
return Stream.of(Arguments.of(new Object[] { types }));
}
@ClassPathOverrides("org.springframework:spring-context:4.1.0.RELEASE")
static class ClassWithOverride {
}
}