001/*
002 * Copyright 2012-2018 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.boot.test.context;
018
019import java.lang.annotation.Annotation;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.HashSet;
023import java.util.List;
024import java.util.Set;
025
026import org.apache.commons.logging.Log;
027import org.apache.commons.logging.LogFactory;
028
029import org.springframework.boot.SpringBootConfiguration;
030import org.springframework.boot.WebApplicationType;
031import org.springframework.boot.context.properties.bind.Bindable;
032import org.springframework.boot.context.properties.bind.Binder;
033import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
034import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
035import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
036import org.springframework.core.annotation.AnnotatedElementUtils;
037import org.springframework.core.annotation.AnnotationUtils;
038import org.springframework.core.env.Environment;
039import org.springframework.core.io.support.SpringFactoriesLoader;
040import org.springframework.test.context.ContextConfiguration;
041import org.springframework.test.context.ContextConfigurationAttributes;
042import org.springframework.test.context.ContextHierarchy;
043import org.springframework.test.context.ContextLoader;
044import org.springframework.test.context.MergedContextConfiguration;
045import org.springframework.test.context.TestContext;
046import org.springframework.test.context.TestContextBootstrapper;
047import org.springframework.test.context.TestExecutionListener;
048import org.springframework.test.context.support.DefaultTestContextBootstrapper;
049import org.springframework.test.context.support.TestPropertySourceUtils;
050import org.springframework.test.context.web.WebAppConfiguration;
051import org.springframework.test.context.web.WebMergedContextConfiguration;
052import org.springframework.util.Assert;
053import org.springframework.util.ClassUtils;
054import org.springframework.util.ObjectUtils;
055import org.springframework.util.StringUtils;
056
057/**
058 * {@link TestContextBootstrapper} for Spring Boot. Provides support for
059 * {@link SpringBootTest @SpringBootTest} and may also be used directly or subclassed.
060 * Provides the following features over and above {@link DefaultTestContextBootstrapper}:
061 * <ul>
062 * <li>Uses {@link SpringBootContextLoader} as the
063 * {@link #getDefaultContextLoaderClass(Class) default context loader}.</li>
064 * <li>Automatically searches for a
065 * {@link SpringBootConfiguration @SpringBootConfiguration} when required.</li>
066 * <li>Allows custom {@link Environment} {@link #getProperties(Class)} to be defined.</li>
067 * <li>Provides support for different {@link WebEnvironment webEnvironment} modes.</li>
068 * </ul>
069 *
070 * @author Phillip Webb
071 * @author Andy Wilkinson
072 * @author Brian Clozel
073 * @author Madhura Bhave
074 * @since 1.4.0
075 * @see SpringBootTest
076 * @see TestConfiguration
077 */
078public class SpringBootTestContextBootstrapper extends DefaultTestContextBootstrapper {
079
080        private static final String[] WEB_ENVIRONMENT_CLASSES = { "javax.servlet.Servlet",
081                        "org.springframework.web.context.ConfigurableWebApplicationContext" };
082
083        private static final String REACTIVE_WEB_ENVIRONMENT_CLASS = "org.springframework."
084                        + "web.reactive.DispatcherHandler";
085
086        private static final String MVC_WEB_ENVIRONMENT_CLASS = "org.springframework."
087                        + "web.servlet.DispatcherServlet";
088
089        private static final String JERSEY_WEB_ENVIRONMENT_CLASS = "org.glassfish.jersey.server.ResourceConfig";
090
091        private static final String ACTIVATE_SERVLET_LISTENER = "org.springframework.test."
092                        + "context.web.ServletTestExecutionListener.activateListener";
093
094        private static final Log logger = LogFactory
095                        .getLog(SpringBootTestContextBootstrapper.class);
096
097        @Override
098        public TestContext buildTestContext() {
099                TestContext context = super.buildTestContext();
100                verifyConfiguration(context.getTestClass());
101                WebEnvironment webEnvironment = getWebEnvironment(context.getTestClass());
102                if (webEnvironment == WebEnvironment.MOCK
103                                && deduceWebApplicationType() == WebApplicationType.SERVLET) {
104                        context.setAttribute(ACTIVATE_SERVLET_LISTENER, true);
105                }
106                else if (webEnvironment != null && webEnvironment.isEmbedded()) {
107                        context.setAttribute(ACTIVATE_SERVLET_LISTENER, false);
108                }
109                return context;
110        }
111
112        @Override
113        protected Set<Class<? extends TestExecutionListener>> getDefaultTestExecutionListenerClasses() {
114                Set<Class<? extends TestExecutionListener>> listeners = super.getDefaultTestExecutionListenerClasses();
115                List<DefaultTestExecutionListenersPostProcessor> postProcessors = SpringFactoriesLoader
116                                .loadFactories(DefaultTestExecutionListenersPostProcessor.class,
117                                                getClass().getClassLoader());
118                for (DefaultTestExecutionListenersPostProcessor postProcessor : postProcessors) {
119                        listeners = postProcessor.postProcessDefaultTestExecutionListeners(listeners);
120                }
121                return listeners;
122        }
123
124        @Override
125        protected ContextLoader resolveContextLoader(Class<?> testClass,
126                        List<ContextConfigurationAttributes> configAttributesList) {
127                Class<?>[] classes = getClasses(testClass);
128                if (!ObjectUtils.isEmpty(classes)) {
129                        for (ContextConfigurationAttributes configAttributes : configAttributesList) {
130                                addConfigAttributesClasses(configAttributes, classes);
131                        }
132                }
133                return super.resolveContextLoader(testClass, configAttributesList);
134        }
135
136        private void addConfigAttributesClasses(
137                        ContextConfigurationAttributes configAttributes, Class<?>[] classes) {
138                List<Class<?>> combined = new ArrayList<>();
139                combined.addAll(Arrays.asList(classes));
140                if (configAttributes.getClasses() != null) {
141                        combined.addAll(Arrays.asList(configAttributes.getClasses()));
142                }
143                configAttributes.setClasses(ClassUtils.toClassArray(combined));
144        }
145
146        @Override
147        protected Class<? extends ContextLoader> getDefaultContextLoaderClass(
148                        Class<?> testClass) {
149                return SpringBootContextLoader.class;
150        }
151
152        @Override
153        protected MergedContextConfiguration processMergedContextConfiguration(
154                        MergedContextConfiguration mergedConfig) {
155                Class<?>[] classes = getOrFindConfigurationClasses(mergedConfig);
156                List<String> propertySourceProperties = getAndProcessPropertySourceProperties(
157                                mergedConfig);
158                mergedConfig = createModifiedConfig(mergedConfig, classes,
159                                StringUtils.toStringArray(propertySourceProperties));
160                WebEnvironment webEnvironment = getWebEnvironment(mergedConfig.getTestClass());
161                if (webEnvironment != null && isWebEnvironmentSupported(mergedConfig)) {
162                        WebApplicationType webApplicationType = getWebApplicationType(mergedConfig);
163                        if (webApplicationType == WebApplicationType.SERVLET
164                                        && (webEnvironment.isEmbedded()
165                                                        || webEnvironment == WebEnvironment.MOCK)) {
166                                WebAppConfiguration webAppConfiguration = AnnotatedElementUtils
167                                                .findMergedAnnotation(mergedConfig.getTestClass(),
168                                                                WebAppConfiguration.class);
169                                String resourceBasePath = (webAppConfiguration != null)
170                                                ? webAppConfiguration.value() : "src/main/webapp";
171                                mergedConfig = new WebMergedContextConfiguration(mergedConfig,
172                                                resourceBasePath);
173                        }
174                        else if (webApplicationType == WebApplicationType.REACTIVE
175                                        && (webEnvironment.isEmbedded()
176                                                        || webEnvironment == WebEnvironment.MOCK)) {
177                                return new ReactiveWebMergedContextConfiguration(mergedConfig);
178                        }
179                }
180                return mergedConfig;
181        }
182
183        private WebApplicationType getWebApplicationType(
184                        MergedContextConfiguration configuration) {
185                ConfigurationPropertySource source = new MapConfigurationPropertySource(
186                                TestPropertySourceUtils.convertInlinedPropertiesToMap(
187                                                configuration.getPropertySourceProperties()));
188                Binder binder = new Binder(source);
189                return binder
190                                .bind("spring.main.web-application-type",
191                                                Bindable.of(WebApplicationType.class))
192                                .orElseGet(this::deduceWebApplicationType);
193        }
194
195        private WebApplicationType deduceWebApplicationType() {
196                if (ClassUtils.isPresent(REACTIVE_WEB_ENVIRONMENT_CLASS, null)
197                                && !ClassUtils.isPresent(MVC_WEB_ENVIRONMENT_CLASS, null)
198                                && !ClassUtils.isPresent(JERSEY_WEB_ENVIRONMENT_CLASS, null)) {
199                        return WebApplicationType.REACTIVE;
200                }
201                for (String className : WEB_ENVIRONMENT_CLASSES) {
202                        if (!ClassUtils.isPresent(className, null)) {
203                                return WebApplicationType.NONE;
204                        }
205                }
206                return WebApplicationType.SERVLET;
207        }
208
209        private boolean isWebEnvironmentSupported(MergedContextConfiguration mergedConfig) {
210                Class<?> testClass = mergedConfig.getTestClass();
211                ContextHierarchy hierarchy = AnnotationUtils.getAnnotation(testClass,
212                                ContextHierarchy.class);
213                if (hierarchy == null || hierarchy.value().length == 0) {
214                        return true;
215                }
216                ContextConfiguration[] configurations = hierarchy.value();
217                return isFromConfiguration(mergedConfig,
218                                configurations[configurations.length - 1]);
219        }
220
221        private boolean isFromConfiguration(MergedContextConfiguration candidateConfig,
222                        ContextConfiguration configuration) {
223                ContextConfigurationAttributes attributes = new ContextConfigurationAttributes(
224                                candidateConfig.getTestClass(), configuration);
225                Set<Class<?>> configurationClasses = new HashSet<>(
226                                Arrays.asList(attributes.getClasses()));
227                for (Class<?> candidate : candidateConfig.getClasses()) {
228                        if (configurationClasses.contains(candidate)) {
229                                return true;
230                        }
231                }
232                return false;
233        }
234
235        protected Class<?>[] getOrFindConfigurationClasses(
236                        MergedContextConfiguration mergedConfig) {
237                Class<?>[] classes = mergedConfig.getClasses();
238                if (containsNonTestComponent(classes) || mergedConfig.hasLocations()) {
239                        return classes;
240                }
241                Class<?> found = new AnnotatedClassFinder(SpringBootConfiguration.class)
242                                .findFromClass(mergedConfig.getTestClass());
243                Assert.state(found != null,
244                                "Unable to find a @SpringBootConfiguration, you need to use "
245                                                + "@ContextConfiguration or @SpringBootTest(classes=...) "
246                                                + "with your test");
247                logger.info("Found @SpringBootConfiguration " + found.getName() + " for test "
248                                + mergedConfig.getTestClass());
249                return merge(found, classes);
250        }
251
252        private boolean containsNonTestComponent(Class<?>[] classes) {
253                for (Class<?> candidate : classes) {
254                        if (!AnnotatedElementUtils.isAnnotated(candidate, TestConfiguration.class)) {
255                                return true;
256                        }
257                }
258                return false;
259        }
260
261        private Class<?>[] merge(Class<?> head, Class<?>[] existing) {
262                Class<?>[] result = new Class<?>[existing.length + 1];
263                result[0] = head;
264                System.arraycopy(existing, 0, result, 1, existing.length);
265                return result;
266        }
267
268        private List<String> getAndProcessPropertySourceProperties(
269                        MergedContextConfiguration mergedConfig) {
270                List<String> propertySourceProperties = new ArrayList<>(
271                                Arrays.asList(mergedConfig.getPropertySourceProperties()));
272                String differentiator = getDifferentiatorPropertySourceProperty();
273                if (differentiator != null) {
274                        propertySourceProperties.add(differentiator);
275                }
276                processPropertySourceProperties(mergedConfig, propertySourceProperties);
277                return propertySourceProperties;
278        }
279
280        /**
281         * Return a "differentiator" property to ensure that there is something to
282         * differentiate regular tests and bootstrapped tests. Without this property a cached
283         * context could be returned that wasn't created by this bootstrapper. By default uses
284         * the bootstrapper class as a property.
285         * @return the differentiator or {@code null}
286         */
287        protected String getDifferentiatorPropertySourceProperty() {
288                return getClass().getName() + "=true";
289        }
290
291        /**
292         * Post process the property source properties, adding or removing elements as
293         * required.
294         * @param mergedConfig the merged context configuration
295         * @param propertySourceProperties the property source properties to process
296         */
297        protected void processPropertySourceProperties(
298                        MergedContextConfiguration mergedConfig,
299                        List<String> propertySourceProperties) {
300                Class<?> testClass = mergedConfig.getTestClass();
301                String[] properties = getProperties(testClass);
302                if (!ObjectUtils.isEmpty(properties)) {
303                        // Added first so that inlined properties from @TestPropertySource take
304                        // precedence
305                        propertySourceProperties.addAll(0, Arrays.asList(properties));
306                }
307                if (getWebEnvironment(testClass) == WebEnvironment.RANDOM_PORT) {
308                        propertySourceProperties.add("server.port=0");
309                }
310        }
311
312        /**
313         * Return the {@link WebEnvironment} type for this test or null if undefined.
314         * @param testClass the source test class
315         * @return the {@link WebEnvironment} or {@code null}
316         */
317        protected WebEnvironment getWebEnvironment(Class<?> testClass) {
318                SpringBootTest annotation = getAnnotation(testClass);
319                return (annotation != null) ? annotation.webEnvironment() : null;
320        }
321
322        protected Class<?>[] getClasses(Class<?> testClass) {
323                SpringBootTest annotation = getAnnotation(testClass);
324                return (annotation != null) ? annotation.classes() : null;
325        }
326
327        protected String[] getProperties(Class<?> testClass) {
328                SpringBootTest annotation = getAnnotation(testClass);
329                return (annotation != null) ? annotation.properties() : null;
330        }
331
332        protected SpringBootTest getAnnotation(Class<?> testClass) {
333                return AnnotatedElementUtils.getMergedAnnotation(testClass, SpringBootTest.class);
334        }
335
336        protected void verifyConfiguration(Class<?> testClass) {
337                SpringBootTest springBootTest = getAnnotation(testClass);
338                if (springBootTest != null
339                                && (springBootTest.webEnvironment() == WebEnvironment.DEFINED_PORT
340                                                || springBootTest.webEnvironment() == WebEnvironment.RANDOM_PORT)
341                                && getAnnotation(WebAppConfiguration.class, testClass) != null) {
342                        throw new IllegalStateException("@WebAppConfiguration should only be used "
343                                        + "with @SpringBootTest when @SpringBootTest is configured with a "
344                                        + "mock web environment. Please remove @WebAppConfiguration or "
345                                        + "reconfigure @SpringBootTest.");
346                }
347        }
348
349        private <T extends Annotation> T getAnnotation(Class<T> annotationType,
350                        Class<?> testClass) {
351                return AnnotatedElementUtils.getMergedAnnotation(testClass, annotationType);
352        }
353
354        /**
355         * Create a new {@link MergedContextConfiguration} with different classes.
356         * @param mergedConfig the source config
357         * @param classes the replacement classes
358         * @return a new {@link MergedContextConfiguration}
359         */
360        protected final MergedContextConfiguration createModifiedConfig(
361                        MergedContextConfiguration mergedConfig, Class<?>[] classes) {
362                return createModifiedConfig(mergedConfig, classes,
363                                mergedConfig.getPropertySourceProperties());
364        }
365
366        /**
367         * Create a new {@link MergedContextConfiguration} with different classes and
368         * properties.
369         * @param mergedConfig the source config
370         * @param classes the replacement classes
371         * @param propertySourceProperties the replacement properties
372         * @return a new {@link MergedContextConfiguration}
373         */
374        protected final MergedContextConfiguration createModifiedConfig(
375                        MergedContextConfiguration mergedConfig, Class<?>[] classes,
376                        String[] propertySourceProperties) {
377                return new MergedContextConfiguration(mergedConfig.getTestClass(),
378                                mergedConfig.getLocations(), classes,
379                                mergedConfig.getContextInitializerClasses(),
380                                mergedConfig.getActiveProfiles(),
381                                mergedConfig.getPropertySourceLocations(), propertySourceProperties,
382                                mergedConfig.getContextCustomizers(), mergedConfig.getContextLoader(),
383                                getCacheAwareContextLoaderDelegate(), mergedConfig.getParent());
384        }
385
386}