springboot单元测试Unable to find a @
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);
}