Java

springboot单元测试Unable to find a @

2019-10-15  本文已影响0人  提米锅锅

springboot单元测试大部分情况很简单,只用增加2个注解就行:

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest

注意是大部分情况,因为springboot约定大于配置,如果你不按它的约定,就会出现下面的错误
Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test

这个是项目结构:


image.png

Service1Application是springboot启动类。

我们先分析问题,单元测试要加载spring环境,必须找到main里面的spring启动类Service1Application,出问题的原因就在于自动配置机制找不到这个类,从而无法加载spring环境。

定位问题从异常堆栈入手,然后增加断点调试,这个是基本的套路。

at org.springframework.util.Assert.state(Assert.java:73)
at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.getOrFindConfigurationClasses(SpringBootTestContextBootstrapper.java:240)
at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.processMergedContextConfiguration(SpringBootTestContextBootstrapper.java:153)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:395)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildDefaultMergedContextConfiguration(AbstractTestContextBootstrapper.java:312)

很容易看到错误是在SpringBootTestContextBootstrappe的getOrFindConfigurationClasses中,findFromClass这个函数找不到用于启动的配置类,需要去看findFromClass到底是怎么去找的。

protected Class<?>[] getOrFindConfigurationClasses(
            MergedContextConfiguration mergedConfig) {
        Class<?>[] classes = mergedConfig.getClasses();
        if (containsNonTestComponent(classes) || mergedConfig.hasLocations()) {
            return classes;
        }
        Class<?> found = new SpringBootConfigurationFinder()
                .findFromClass(mergedConfig.getTestClass());
        Assert.state(found != null,
                "Unable to find a @SpringBootConfiguration, you need to use "
                        + "@ContextConfiguration or @SpringBootTest(classes=...) "
                        + "with your test");
        logger.info("Found @SpringBootConfiguration " + found.getName() + " for test "
                + mergedConfig.getTestClass());
        return merge(found, classes);
    }

findFromClass是个空壳方法,不得不顺着调用链往下找~~~

    public Class<?> findFromClass(Class<?> source) {
        Assert.notNull(source, "Source must not be null");
        return findFromPackage(ClassUtils.getPackageName(source));
    }
private Class<?> scanPackage(String source) {
        while (!source.isEmpty()) {
            Set<BeanDefinition> components = this.scanner.findCandidateComponents(source);
            if (!components.isEmpty()) {
                Assert.state(components.size() == 1,
                        () -> "Found multiple @SpringBootConfiguration annotated classes "
                                + components);
                return ClassUtils.resolveClassName(
                        components.iterator().next().getBeanClassName(), null);
            }
            source = getParentPackage(source);
        }
        return null;
    }

这个有点样子了,但是类内容不多,看来干货在scanCandidateComponents。

这个函数前3行代码是关键,packageSearchPath用测试类的basePackage作为后缀,前面加上了classpath, 也就是 classpath:com/test/example/demo/myservice/*.class,那么它会去所有classpath下的这个路径去找配置类。

private Set<BeanDefinition> scanCandidateComponents(String basePackage) {
        Set<BeanDefinition> candidates = new LinkedHashSet<>();
        try {
            String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
                    resolveBasePackage(basePackage) + '/' + this.resourcePattern;
            Resource[] resources = getResourcePatternResolver().getResources(packageSearchPath);
            boolean traceEnabled = logger.isTraceEnabled();
            boolean debugEnabled = logger.isDebugEnabled();
            for (Resource resource : resources) {
                if (traceEnabled) {
                    logger.trace("Scanning " + resource);
                }
                if (resource.isReadable()) {
                    try {
                        MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource);
                        if (isCandidateComponent(metadataReader)) {
                            ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
                            sbd.setResource(resource);
                            sbd.setSource(resource);

到这里终于发现原来我的测试包路径是com.test.example.demo.myservice,而我的主程序包路径是com.example.demo.myservice,中间多了一个test,导致找不到。


image.png

spring单元测试指定配置类的第二种方式。
问题到了这里并没有完,网上也有人说即使测试的package和代码package不一样,通过指定@SpringBootTest(classes = {Service1Application.class})也可以。

我们修改程序测试下

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {Service1Application.class})
public class testRedis {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Test
    public  void  run(){
    }
}

结果真的可以运行,所有我们继续看看这种方式又是如何找到配置类的。

虽然我们不了解springtest调用链,但知道既然是springboot项目,那必然会创建SpringApplication对象,所有可以在SpringApplication的构造函函数打个断点开始。


image.png

org.springframework.boot.test.context的SpringBootContextLoader看起来是测试框架里负责初始化SpringApplication的地方,代码可以看出这个函数自己new了一个SpringApplication 对象,然后根据传入的参数MergedContextConfiguration 对SpringApplication做了初始化。

@Override
    public ApplicationContext loadContext(MergedContextConfiguration config)
            throws Exception {
        Class<?>[] configClasses = config.getClasses();
        String[] configLocations = config.getLocations();
        Assert.state(
                !ObjectUtils.isEmpty(configClasses)
                        || !ObjectUtils.isEmpty(configLocations),
                () -> "No configuration classes "
                        + "or locations found in @SpringApplicationConfiguration. "
                        + "For default configuration detection to work you need "
                        + "Spring 4.0.3 or better (found " + SpringVersion.getVersion()
                        + ").");
        SpringApplication application = getSpringApplication();
        application.setMainApplicationClass(config.getTestClass());
        application.addPrimarySources(Arrays.asList(configClasses));
        application.getSources().addAll(Arrays.asList(configLocations));
        ConfigurableEnvironment environment = getEnvironment();
        if (!ObjectUtils.isEmpty(config.getActiveProfiles())) {
            setActiveProfiles(environment, config.getActiveProfiles());
        }

image.png

spring单元测试其实并不会执行我们源代码的main函数,但是为了模拟程序的环境,它必须拿到Service1Application这个类上配置的全部注解信息。

再看下config类的信息可以发现Service1Application已经在里面,所以需要继续往前找到config里classes的值是怎么被赋上去的。

因为调用链很长,我们可以用2分法来加快速度。

    @Override
    public TestContext buildTestContext() {
        return new DefaultTestContext(getBootstrapContext().getTestClass(), buildMergedContextConfiguration(),
                getCacheAwareContextLoaderDelegate());
    }

直接断到AbstractTestContextBootstrapper的buildTestContext,这个类看起来是准备测试环境的,看一下什么都还没创建,可以直接往下走。

    @Override
    public TestContext buildTestContext() {
        return new DefaultTestContext(getBootstrapContext().getTestClass(), buildMergedContextConfiguration(),
                getCacheAwareContextLoaderDelegate());
    }

看名字应该是buildMergedContextConfiguration

image.png

函数里new了一个defaultConfigAttributesList 对象,看名字猜测就是这是这个对象用于保存配置信息,此时classes属性还没有值,说明springapplication类还没被解析。


image.png
private MergedContextConfiguration buildDefaultMergedContextConfiguration(Class<?> testClass,
            CacheAwareContextLoaderDelegate cacheAwareContextLoaderDelegate) {

        List<ContextConfigurationAttributes> defaultConfigAttributesList =
                Collections.singletonList(new ContextConfigurationAttributes(testClass));

        ContextLoader contextLoader = resolveContextLoader(testClass, defaultConfigAttributesList);
        if (logger.isInfoEnabled()) {
            logger.info(String.format(
                    "Neither @ContextConfiguration nor @ContextHierarchy found for test class [%s], using %s",
                    testClass.getName(), contextLoader.getClass().getSimpleName()));
        }
        return buildMergedContextConfiguration(testClass, defaultConfigAttributesList, null,
                cacheAwareContextLoaderDelegate, false);
    }

继续跟进到resolveContextLoader函数,resolveContextLoader的入参

testClass就是单元测试类 testRedis,这个函数会找到解析出testRedis上的注解信息。
getclass方法用一个SpringBootTest 类型的对象解析出标准的spring配置类结果,见下图。

@Override
    protected ContextLoader resolveContextLoader(Class<?> testClass,
            List<ContextConfigurationAttributes> configAttributesList) {
        Class<?>[] classes = getClasses(testClass);
        if (!ObjectUtils.isEmpty(classes)) {
            for (ContextConfigurationAttributes configAttributes : configAttributesList) {
                addConfigAttributesClasses(configAttributes, classes);
            }
        }
        return super.resolveContextLoader(testClass, configAttributesList);
    }
protected Class<?>[] getClasses(Class<?> testClass) {
        SpringBootTest annotation = getAnnotation(testClass);
        return (annotation != null ? annotation.classes() : null);
    }

SpringBootTest类型对象annotation 已经提取到了配置类


image.png

然后会把Service1Application塞到configAttributesList的class属性中,而在buildMergedContextConfiguration里,又会根据configAttributesList来构造一个MergedContextConfiguration对象,最终将这个MergedContextConfiguration 一步一步往下传到loadContext函数,用于初始化SpringApplication对象。

删减部分无关代码

private MergedContextConfiguration buildMergedContextConfiguration(Class<?> testClass,
            List<ContextConfigurationAttributes> configAttributesList, @Nullable MergedContextConfiguration parentConfig,
            CacheAwareContextLoaderDelegate cacheAwareContextLoaderDelegate,
            boolean requireLocationsClassesOrInitializers) {

        
        Set<ContextCustomizer> contextCustomizers = getContextCustomizers(testClass,
                Collections.unmodifiableList(configAttributesList));

        MergedTestPropertySources mergedTestPropertySources =
                TestPropertySourceUtils.buildMergedTestPropertySources(testClass);
        MergedContextConfiguration mergedConfig = new MergedContextConfiguration(testClass,
                StringUtils.toStringArray(locations), ClassUtils.toClassArray(classes),
                ApplicationContextInitializerUtils.resolveInitializerClasses(configAttributesList),
                ActiveProfilesUtils.resolveActiveProfiles(testClass),
                mergedTestPropertySources.getLocations(),
                mergedTestPropertySources.getProperties(),
                contextCustomizers, contextLoader, cacheAwareContextLoaderDelegate, parentConfig);

        return processMergedContextConfiguration(mergedConfig);
    }
上一篇下一篇

猜你喜欢

热点阅读