diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java index 84e3906987b..efe2c3ae1dd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java @@ -32,7 +32,7 @@ import org.junit.jupiter.api.extension.ExtendWith; * @since 1.5.0 */ @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) +@Target({ ElementType.TYPE, ElementType.METHOD }) @Documented @ExtendWith(ModifiedClassPathExtension.class) public @interface ClassPathExclusions { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java index b90169268c7..9e78a28ab73 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java @@ -31,7 +31,7 @@ import org.junit.jupiter.api.extension.ExtendWith; * @since 1.5.0 */ @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) +@Target({ ElementType.TYPE, ElementType.METHOD }) @Documented @ExtendWith(ModifiedClassPathExtension.class) public @interface ClassPathOverrides { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java index 30168fa77fa..47cbe20d80e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java @@ -25,15 +25,15 @@ import java.lang.annotation.Target; import org.junit.jupiter.api.extension.ExtendWith; /** - * Annotation used to fork the classpath. This can be helpful where neither - * {@link ClassPathExclusions} or {@link ClassPathOverrides} are needed, but just a copy - * of the classpath. + * Annotation used to fork the classpath. This can be helpful when using annotations on + * parameterized tests, or where neither {@link ClassPathExclusions} or + * {@link ClassPathOverrides} are needed, but just a copy of the classpath. * * @author Christoph Dreis * @since 2.4.0 */ @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) +@Target({ ElementType.TYPE, ElementType.METHOD }) @Documented @ExtendWith(ModifiedClassPathExtension.class) public @interface ForkedClassPath { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java index 528e32c5920..234bdfdd7bd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java @@ -18,17 +18,22 @@ package org.springframework.boot.testsupport.classpath; import java.io.File; import java.lang.management.ManagementFactory; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; import java.net.URISyntaxException; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.jar.Attributes; import java.util.jar.JarFile; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.stream.Stream; 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.util.AntPathMatcher; import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** @@ -62,7 +68,7 @@ import org.springframework.util.StringUtils; */ final class ModifiedClassPathClassLoader extends URLClassLoader { - private static final Map, ModifiedClassPathClassLoader> cache = new ConcurrentReferenceHashMap<>(); + private static final Map, ModifiedClassPathClassLoader> cache = new ConcurrentReferenceHashMap<>(); 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); } - static ModifiedClassPathClassLoader get(Class testClass) { - return cache.computeIfAbsent(testClass, ModifiedClassPathClassLoader::compute); + static ModifiedClassPathClassLoader get(Class testClass, Method testMethod, List arguments) { + Set candidates = new LinkedHashSet<>(); + candidates.add(testClass); + candidates.add(testMethod); + candidates.addAll(getAnnotatedElements(arguments.toArray())); + List 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) { - ClassLoader classLoader = testClass.getClassLoader(); - MergedAnnotations annotations = MergedAnnotations.from(testClass, - MergedAnnotations.SearchStrategy.TYPE_HIERARCHY); - if (annotations.isPresent(ForkedClassPath.class) && (annotations.isPresent(ClassPathOverrides.class) - || annotations.isPresent(ClassPathExclusions.class))) { - throw new IllegalStateException("@ForkedClassPath is redundant in combination with either " - + "@ClassPathOverrides or @ClassPathExclusions"); + private static Collection getAnnotatedElements(Object[] array) { + Set result = new LinkedHashSet<>(); + for (Object item : array) { + if (item instanceof AnnotatedElement) { + result.add((AnnotatedElement) item); + } + else if (ObjectUtils.isArray(item)) { + 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 annotatedClasses) { + List annotations = annotatedClasses.stream() + .map((source) -> MergedAnnotations.from(source, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY)) + .collect(Collectors.toList()); return new ModifiedClassPathClassLoader(processUrls(extractUrls(classLoader), annotations), classLoader.getParent(), classLoader); } @@ -174,9 +205,9 @@ final class ModifiedClassPathClassLoader extends URLClassLoader { } } - private static URL[] processUrls(URL[] urls, MergedAnnotations annotations) { - ClassPathEntryFilter filter = new ClassPathEntryFilter(annotations.get(ClassPathExclusions.class)); - List additionalUrls = getAdditionalUrls(annotations.get(ClassPathOverrides.class)); + private static URL[] processUrls(URL[] urls, List annotations) { + ClassPathEntryFilter filter = new ClassPathEntryFilter(annotations); + List additionalUrls = getAdditionalUrls(annotations); List processedUrls = new ArrayList<>(additionalUrls); for (URL url : urls) { if (!filter.isExcluded(url)) { @@ -186,11 +217,15 @@ final class ModifiedClassPathClassLoader extends URLClassLoader { return processedUrls.toArray(new URL[0]); } - private static List getAdditionalUrls(MergedAnnotation annotation) { - if (!annotation.isPresent()) { - return Collections.emptyList(); + private static List getAdditionalUrls(List annotations) { + Set urls = new LinkedHashSet<>(); + for (MergedAnnotations candidate : annotations) { + MergedAnnotation 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 resolveCoordinates(String[] coordinates) { @@ -241,9 +276,15 @@ final class ModifiedClassPathClassLoader extends URLClassLoader { private final AntPathMatcher matcher = new AntPathMatcher(); - private ClassPathEntryFilter(MergedAnnotation annotation) { - this.exclusions = annotation.getValue(MergedAnnotation.VALUE, String[].class).map(Arrays::asList) - .orElse(Collections.emptyList()); + private ClassPathEntryFilter(List annotations) { + Set exclusions = new LinkedHashSet<>(); + for (MergedAnnotations candidate : annotations) { + MergedAnnotation 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) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtension.java index 90a6d6c3344..a53880fda17 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtension.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtension.java @@ -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"); * you may not use this file except in compliance with the License. @@ -46,7 +46,7 @@ import org.springframework.util.ReflectionUtils; * * @author Christoph Dreis */ -class ModifiedClassPathExtension implements InvocationInterceptor { +public class ModifiedClassPathExtension implements InvocationInterceptor { @Override public void interceptBeforeAllMethod(Invocation invocation, @@ -75,20 +75,31 @@ class ModifiedClassPathExtension implements InvocationInterceptor { @Override public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptMethod(invocation, invocationContext, extensionContext); + } + + @Override + public void interceptTestTemplateMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptMethod(invocation, invocationContext, extensionContext); + } + + private void interceptMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { if (isModifiedClassPathClassLoader(extensionContext)) { invocation.proceed(); return; } - invocation.skip(); - runTestWithModifiedClassPath(invocationContext, extensionContext); - } - - private void runTestWithModifiedClassPath(ReflectiveInvocationContext invocationContext, - ExtensionContext extensionContext) throws Throwable { Class testClass = extensionContext.getRequiredTestClass(); 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(); - URLClassLoader modifiedClassLoader = ModifiedClassPathClassLoader.get(testClass); Thread.currentThread().setContextClassLoader(modifiedClassLoader); try { runTest(modifiedClassLoader, testClass.getName(), testMethod.getName()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionOverridesParametrizedTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionOverridesParametrizedTests.java new file mode 100644 index 00000000000..23214d8ec70 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionOverridesParametrizedTests.java @@ -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 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 { + + } + +}