Commit 6035fe4c authored by Andy Wilkinson's avatar Andy Wilkinson

Make it easier to run JUnit tests against a subset of the class path

Due to `@ConditionalOnClass` and `@ConditionalOnMissingClass`, the
behaviour of many auto-configuration classes is dependant on the
contents of the class path, yet we do not have a lightweight way of
testing such classes against a specific class path.

This commit introduces FilteredClassPathRunner, a JUnit Runner that
runs a class’s tests using a filtered class path. A
`@ClassPathExclusions` annotation on a test class can be used to
filter entries from the project’s default class path, thereby allowing
a configuration class’s behaviour in the presence or absence of
certain classes to be tested more easily.

Closes gh-5359
parent b260c20d
/*
* Copyright 2012-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.boot.testutil;
import java.io.File;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation used in combination with {@link FilteredClassPathRunner} to exclude entries
* from the classpath.
*
* @author Andy Wilkinson
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ClassPathExclusions {
/**
* One or more Ant-style patterns that identify entries to be excluded from the class
* path. Matching is performed against an entry's {@link File#getName() file name}.
* For example, to exclude Hibernate Validator from the classpath,
* {@code "hibernate-validator-*.jar"} can be used.
*
* @return the exclusion patterns
*/
String[] value();
}
/*
* Copyright 2012-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.boot.testutil;
import java.io.File;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.TestClass;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
/**
* A custom {@link BlockJUnit4ClassRunner} that runs tests using a filtered class path.
* Entries are excluded from the class path using {@link ClassPathExclusions} 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 Andy Wilkinson
*/
public class FilteredClassPathRunner extends BlockJUnit4ClassRunner {
public FilteredClassPathRunner(Class<?> klass) throws InitializationError {
super(klass);
}
@Override
protected TestClass createTestClass(Class<?> testClass) {
try {
final ClassLoader classLoader = createTestClassLoader(testClass);
return new TestClass(classLoader.loadClass(testClass.getName())) {
@SuppressWarnings("unchecked")
@Override
public List<FrameworkMethod> getAnnotatedMethods(
Class<? extends Annotation> annotationClass) {
List<FrameworkMethod> methods = new ArrayList<FrameworkMethod>();
try {
for (FrameworkMethod frameworkMethod : super.getAnnotatedMethods(
(Class<? extends Annotation>) classLoader
.loadClass(annotationClass.getName()))) {
methods.add(new CustomTcclFrameworkMethod(classLoader,
frameworkMethod.getMethod()));
}
return methods;
}
catch (ClassNotFoundException ex) {
throw new RuntimeException(ex);
}
}
};
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private URLClassLoader createTestClassLoader(Class<?> testClass) throws Exception {
URLClassLoader classLoader = (URLClassLoader) this.getClass().getClassLoader();
return new URLClassLoader(filterUrls(extractUrls(classLoader), testClass),
classLoader.getParent());
}
private URL[] extractUrls(URLClassLoader classLoader) throws Exception {
List<URL> extractedUrls = new ArrayList<URL>();
for (URL url : classLoader.getURLs()) {
if (isSurefireBooterJar(url)) {
extractedUrls.addAll(extractUrlsFromManifestClassPath(url));
}
else {
extractedUrls.add(url);
}
}
return extractedUrls.toArray(new URL[extractedUrls.size()]);
}
private boolean isSurefireBooterJar(URL url) {
return url.getPath().contains("surefirebooter");
}
private List<URL> extractUrlsFromManifestClassPath(URL booterJar) throws Exception {
List<URL> urls = new ArrayList<URL>();
for (String entry : getClassPath(booterJar)) {
urls.add(new URL(entry));
}
return urls;
}
private String[] getClassPath(URL booterJar) throws Exception {
JarFile jarFile = new JarFile(new File(booterJar.toURI()));
try {
return StringUtils.delimitedListToStringArray(jarFile.getManifest()
.getMainAttributes().getValue(Attributes.Name.CLASS_PATH), " ");
}
finally {
jarFile.close();
}
}
private URL[] filterUrls(URL[] urls, Class<?> testClass) throws Exception {
ClassPathEntryFilter filter = new ClassPathEntryFilter(testClass);
List<URL> filteredUrls = new ArrayList<URL>();
for (URL url : urls) {
if (!filter.isExcluded(url)) {
filteredUrls.add(url);
}
}
return filteredUrls.toArray(new URL[filteredUrls.size()]);
}
private static final class ClassPathEntryFilter {
private final List<String> exclusions;
private final AntPathMatcher matcher = new AntPathMatcher();
private ClassPathEntryFilter(Class<?> testClass) throws Exception {
ClassPathExclusions exclusions = AnnotationUtils.findAnnotation(testClass,
ClassPathExclusions.class);
this.exclusions = exclusions == null ? Collections.<String>emptyList()
: Arrays.asList(exclusions.value());
}
private boolean isExcluded(URL url) throws Exception {
if (!"file".equals(url.getProtocol())) {
return false;
}
String name = new File(url.toURI()).getName();
for (String exclusion : this.exclusions) {
if (this.matcher.match(exclusion, name)) {
return true;
}
}
return false;
}
}
private static final class CustomTcclFrameworkMethod extends FrameworkMethod {
private final ClassLoader customTccl;
private CustomTcclFrameworkMethod(ClassLoader customTccl, Method method) {
super(method);
this.customTccl = customTccl;
}
@Override
public Object invokeExplosively(Object target, Object... params)
throws Throwable {
ClassLoader originalTccl = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(this.customTccl);
try {
return super.invokeExplosively(target, params);
}
finally {
Thread.currentThread().setContextClassLoader(originalTccl);
}
}
}
}
/*
* Copyright 2012-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.boot.testutil;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link FilteredClassPathRunner}
*
* @author Andy Wilkinson
*/
@RunWith(FilteredClassPathRunner.class)
@ClassPathExclusions("hibernate-validator-*.jar")
public class FilteredClassPathRunnerTests {
@Test
public void entriesAreFilteredFromTestClassClassLoader() {
assertThat(getClass().getClassLoader()
.getResource("META-INF/services/javax.validation.spi.ValidationProvider"))
.isNull();
}
@Test
public void entriesAreFilteredFromThreadContextClassLoader() {
assertThat(Thread.currentThread().getContextClassLoader()
.getResource("META-INF/services/javax.validation.spi.ValidationProvider"))
.isNull();
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment