From ae1d352d347a6c8953dbfe2b41c0fe6c0abd5cf4 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Tue, 22 Mar 2016 13:02:39 -0700 Subject: [PATCH] Allow @Import to be used directly on test classes Remove the need for a nested @Configuration class when writing a test that need to @Import configuration. Primarily added to allow @ImportAutoConfiguration to be used directly on test classes. Fixes gh-5473 --- .../context/ImportsContextCustomizer.java | 258 ++++++++++++++++++ .../ImportsContextCustomizerFactory.java | 64 +++++ .../main/resources/META-INF/spring.factories | 1 + ...textCustomizerFactoryIntegrationTests.java | 63 +++++ .../ImportsContextCustomizerFactoryTests.java | 156 +++++++++++ 5 files changed, 542 insertions(+) create mode 100644 spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java create mode 100644 spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizerFactory.java create mode 100644 spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryIntegrationTests.java create mode 100644 spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java diff --git a/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java b/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java new file mode 100644 index 0000000000..0e020683cc --- /dev/null +++ b/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java @@ -0,0 +1,258 @@ +/* + * 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.test.context; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.AnnotatedBeanDefinitionReader; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportSelector; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.annotation.Order; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.MergedContextConfiguration; + +/** + * {@link ContextCustomizer} to allow {@code @Import} annotations to be used directly on + * test classes. + * + * @author Phillip Webb + * @see ImportsContextCustomizerFactory + */ +class ImportsContextCustomizer implements ContextCustomizer { + + static final String TEST_CLASS_ATTRIBUTE = "testClass"; + + private final Class testClass; + + private final ContextCustomizerKey key; + + ImportsContextCustomizer(Class testClass) { + this.testClass = testClass; + this.key = new ContextCustomizerKey(testClass); + } + + @Override + public void customizeContext(ConfigurableApplicationContext context, + MergedContextConfiguration mergedContextConfiguration) { + BeanDefinitionRegistry registry = getBeanDefinitionRegistry(context); + AnnotatedBeanDefinitionReader reader = new AnnotatedBeanDefinitionReader( + registry); + registerCleanupPostProcessor(registry, reader); + registerImportsConfiguration(registry, reader); + } + + private void registerCleanupPostProcessor(BeanDefinitionRegistry registry, + AnnotatedBeanDefinitionReader reader) { + BeanDefinition definition = registerBean(registry, reader, + ImportsCleanupPostProcessor.BEAN_NAME, ImportsCleanupPostProcessor.class); + definition.getConstructorArgumentValues().addIndexedArgumentValue(0, + this.testClass); + } + + private void registerImportsConfiguration(BeanDefinitionRegistry registry, + AnnotatedBeanDefinitionReader reader) { + BeanDefinition definition = registerBean(registry, reader, + ImportsConfiguration.BEAN_NAME, ImportsConfiguration.class); + definition.setAttribute(TEST_CLASS_ATTRIBUTE, this.testClass); + } + + private BeanDefinitionRegistry getBeanDefinitionRegistry(ApplicationContext context) { + if (context instanceof BeanDefinitionRegistry) { + return (BeanDefinitionRegistry) context; + } + if (context instanceof AbstractApplicationContext) { + return (BeanDefinitionRegistry) ((AbstractApplicationContext) context) + .getBeanFactory(); + } + throw new IllegalStateException("Could not locate BeanDefinitionRegistry"); + } + + @SuppressWarnings("unchecked") + private BeanDefinition registerBean(BeanDefinitionRegistry registry, + AnnotatedBeanDefinitionReader reader, String beanName, Class type) { + reader.registerBean(type, beanName); + BeanDefinition definition = registry.getBeanDefinition(beanName); + return definition; + } + + @Override + public int hashCode() { + return this.key.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != getClass()) { + return false; + } + // ImportSelectors are flexible so the only safe cache key is the test class + ImportsContextCustomizer other = (ImportsContextCustomizer) obj; + return this.key.equals(other.key); + } + + /** + * {@link Configuration} registered to trigger the {@link ImportsSelector}. + */ + @Configuration + @Import(ImportsSelector.class) + static class ImportsConfiguration { + + static final String BEAN_NAME = ImportsConfiguration.class.getName(); + + } + + /** + * {@link ImportSelector} that returns the original test class so that direct + * {@code @Import} annotations are processed. + */ + static class ImportsSelector implements ImportSelector, BeanFactoryAware { + + private static final String[] NO_IMPORTS = {}; + + private ConfigurableListableBeanFactory beanFactory; + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = (ConfigurableListableBeanFactory) beanFactory; + } + + @Override + public String[] selectImports(AnnotationMetadata importingClassMetadata) { + BeanDefinition definition = this.beanFactory + .getBeanDefinition(ImportsConfiguration.BEAN_NAME); + Object testClass = (definition == null ? null + : definition.getAttribute(TEST_CLASS_ATTRIBUTE)); + return (testClass == null ? NO_IMPORTS + : new String[] { ((Class) testClass).getName() }); + } + + } + + /** + * {@link BeanDefinitionRegistryPostProcessor} to cleanup temporary configuration + * added to load imports. + */ + @Order(Ordered.LOWEST_PRECEDENCE) + static class ImportsCleanupPostProcessor + implements BeanDefinitionRegistryPostProcessor { + + static final String BEAN_NAME = ImportsCleanupPostProcessor.class.getName(); + + private final Class testClass; + + ImportsCleanupPostProcessor(Class testClass) { + this.testClass = testClass; + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) + throws BeansException { + } + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) + throws BeansException { + try { + String[] names = registry.getBeanDefinitionNames(); + for (String name : names) { + BeanDefinition definition = registry.getBeanDefinition(name); + if (this.testClass.getName().equals(definition.getBeanClassName())) { + registry.removeBeanDefinition(name); + } + } + registry.removeBeanDefinition(ImportsConfiguration.BEAN_NAME); + } + catch (NoSuchBeanDefinitionException ex) { + } + } + + } + + /** + * The key used to ensure correct application context caching. Keys are generated + * based on all the annotations used with the test. We must use something + * broader than just {@link Import @Import} annotations since an {@code @Import} may + * use an {@link ImportSelector} which could make decisions based on anything + * available from {@link AnnotationMetadata}. + */ + static class ContextCustomizerKey { + + private final Set annotations; + + ContextCustomizerKey(Class testClass) { + Set annotations = new HashSet(); + collectClassAnnotations(testClass, annotations); + this.annotations = Collections.unmodifiableSet(annotations); + } + + private void collectClassAnnotations(Class classType, + Set annotations) { + collectElementAnnotations(classType, annotations); + for (Class interfaceType : classType.getInterfaces()) { + collectClassAnnotations(interfaceType, annotations); + } + if (classType.getSuperclass() != null) { + collectClassAnnotations(classType.getSuperclass(), annotations); + } + } + + private void collectElementAnnotations(AnnotatedElement element, + Set annotations) { + for (Annotation annotation : element.getDeclaredAnnotations()) { + if (!AnnotationUtils.isInJavaLangAnnotationPackage(annotation)) { + annotations.add(annotation); + collectClassAnnotations(annotation.annotationType(), annotations); + } + } + } + + @Override + public int hashCode() { + return this.annotations.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return (obj != null && getClass().equals(obj.getClass()) + && this.annotations.equals(((ContextCustomizerKey) obj).annotations)); + } + + } + +} diff --git a/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizerFactory.java b/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizerFactory.java new file mode 100644 index 0000000000..c44951548c --- /dev/null +++ b/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizerFactory.java @@ -0,0 +1,64 @@ +/* + * 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.test.context; + +import java.lang.reflect.Method; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.ReflectionUtils.MethodCallback; + +/** + * {@link ContextCustomizerFactory} to allow {@code @Import} annotations to be used + * directly on test classes. + * + * @author Phillip Webb + * @see ImportsContextCustomizer + */ +class ImportsContextCustomizerFactory implements ContextCustomizerFactory { + + @Override + public ContextCustomizer createContextCustomizer(Class testClass, + List configAttributes) { + if (AnnotatedElementUtils.findMergedAnnotation(testClass, Import.class) != null) { + assertHasNoBeanMethods(testClass); + return new ImportsContextCustomizer(testClass); + } + return null; + } + + private void assertHasNoBeanMethods(Class testClass) { + ReflectionUtils.doWithMethods(testClass, new MethodCallback() { + + @Override + public void doWith(Method method) { + Assert.state(!AnnotatedElementUtils.isAnnotated(method, Bean.class), + "Test classes cannot include @Bean methods"); + } + + }); + + } + +} diff --git a/spring-boot-test/src/main/resources/META-INF/spring.factories b/spring-boot-test/src/main/resources/META-INF/spring.factories index 50479f7341..3a72cef79f 100644 --- a/spring-boot-test/src/main/resources/META-INF/spring.factories +++ b/spring-boot-test/src/main/resources/META-INF/spring.factories @@ -1,5 +1,6 @@ # Spring Test ContextCustomizerFactories org.springframework.test.context.ContextCustomizerFactory=\ +org.springframework.boot.test.context.ImportsContextCustomizerFactory,\ org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizerFactory,\ org.springframework.boot.test.context.web.WebIntegrationTestContextCustomizerFactory,\ org.springframework.boot.test.mock.mockito.MockitoContextCustomizerFactory diff --git a/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryIntegrationTests.java b/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryIntegrationTests.java new file mode 100644 index 0000000000..aff1b9c6b6 --- /dev/null +++ b/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryIntegrationTests.java @@ -0,0 +1,63 @@ +/* + * 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.test.context; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.ImportsContextCustomizerFactoryIntegrationTests.ImportedBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Component; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ImportsContextCustomizerFactory} and + * {@link ImportsContextCustomizer}. + * + * @author Phillip Webb + */ +@RunWith(SpringRunner.class) +@Import(ImportedBean.class) +public class ImportsContextCustomizerFactoryIntegrationTests { + + @Autowired + private ApplicationContext context; + + @Autowired + private ImportedBean bean; + + @Test + public void beanWasImported() throws Exception { + assertThat(this.bean).isNotNull(); + } + + @Test(expected = NoSuchBeanDefinitionException.class) + public void testItselfIsNotABean() throws Exception { + this.context.getBean(getClass()); + } + + @Component + static class ImportedBean { + + } + +} diff --git a/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java b/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java new file mode 100644 index 0000000000..6c2f273413 --- /dev/null +++ b/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java @@ -0,0 +1,156 @@ +/* + * 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.test.context; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.MergedContextConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ImportsContextCustomizerFactory} and {@link ImportsContextCustomizer}. + * + * @author Phillip Webb + */ +public class ImportsContextCustomizerFactoryTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private ImportsContextCustomizerFactory factory = new ImportsContextCustomizerFactory(); + + @Test + public void getContextCustomizerWhenHasNoImportAnnotationShouldReturnNull() { + ContextCustomizer customizer = this.factory + .createContextCustomizer(TestWithNoImport.class, null); + assertThat(customizer).isNull(); + } + + @Test + public void getContextCustomizerWhenHasImportAnnotationShouldReturnCustomizer() { + ContextCustomizer customizer = this.factory + .createContextCustomizer(TestWithImport.class, null); + assertThat(customizer).isNotNull(); + } + + @Test + public void getContextCustomizerWhenHasMetaImportAnnotationShouldReturnCustomizer() { + ContextCustomizer customizer = this.factory + .createContextCustomizer(TestWithMetaImport.class, null); + assertThat(customizer).isNotNull(); + } + + @Test + public void contextCustomizerEqualsAndHashCode() throws Exception { + ContextCustomizer customizer1 = this.factory + .createContextCustomizer(TestWithImport.class, null); + ContextCustomizer customizer2 = this.factory + .createContextCustomizer(TestWithImport.class, null); + ContextCustomizer customizer3 = this.factory + .createContextCustomizer(TestWithImportAndMetaImport.class, null); + ContextCustomizer customizer4 = this.factory + .createContextCustomizer(TestWithSameImportAndMetaImport.class, null); + assertThat(customizer1.hashCode()).isEqualTo(customizer1.hashCode()); + assertThat(customizer1.hashCode()).isEqualTo(customizer2.hashCode()); + assertThat(customizer1).isEqualTo(customizer1).isEqualTo(customizer2) + .isNotEqualTo(customizer3); + assertThat(customizer3).isEqualTo(customizer4); + } + + @Test + public void getContextCustomizerWhenClassHasBeanMethodsShouldThrowException() + throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Test classes cannot include @Bean methods"); + this.factory.createContextCustomizer(TestWithImportAndBeanMethod.class, null); + } + + @Test + public void contextCustomizerImportsBeans() throws Exception { + ContextCustomizer customizer = this.factory + .createContextCustomizer(TestWithImport.class, null); + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + customizer.customizeContext(context, mock(MergedContextConfiguration.class)); + context.refresh(); + assertThat(context.getBean(ImportedBean.class)).isNotNull(); + } + + static class TestWithNoImport { + + } + + @Import(ImportedBean.class) + static class TestWithImport { + + } + + @MetaImport + static class TestWithMetaImport { + + } + + @MetaImport + @Import(AnotherImportedBean.class) + static class TestWithImportAndMetaImport { + + } + + @MetaImport + @Import(AnotherImportedBean.class) + static class TestWithSameImportAndMetaImport { + + } + + @Import(ImportedBean.class) + static class TestWithImportAndBeanMethod { + + @Bean + public String bean() { + return "bean"; + } + + } + + @Retention(RetentionPolicy.RUNTIME) + @Import(ImportedBean.class) + @interface MetaImport { + + } + + @Component + static class ImportedBean { + + } + + @Component + static class AnotherImportedBean { + + } + +}