目录
- 背景
- 测试用例的运行流程
- 结论
背景
spring boot test的项目中常用的测试框架, 最近在写集成测试的时候发现一个比较奇怪的问题,当我在运行多个测试用例的时候会偶尔重新启动整个容器上下文,由于后期业务逐渐复杂,大量的测试用例需要运行,这个问题直接导致回归测试的效率降低。
举个例子:
几个类:
@RunWith(SpringRunner.class) @SpringBootTest(classes = TestApplication.class) public class BaseApiTest { @Test public void init() { } } public class ApiTest1 extends BaseApiTest { @MockBean private Service service; @Test public void test1() { service.call(); } } public class ApiTest2 extends BaseApiTest { @Autowired private Service service; @Test public void test2() { service.call(); } } @SpringBootApplication @Slf4j public class TestApplication { public static void main(String[] args) { log.info("启动容器"); new SpringApplication(TestApplication.class).run(args); } } @Component public class Service { public void call() { System.out.println("service called"); } }
运行test包下所有测试:
发现容器重复启动了。
测试用例的运行流程
可以开启idea的线程堆栈跟踪,观察整个容器的启动路径
com.intellij.rt.junit是idea内部的实现,点击idea的运行单测会触发JunitStarter的main函数去启动,可以去GitHub找到源码:
做一些准备工作找到指定的runner就开始调用junit的包去执行编写的单测,junit为了灵活的扩展不同的测试运行环境,类似SPI机制动态获取Runner去运行单测。例如我的例子里指定了SpringRunner就是需要依赖Spring容器的一个实现,这样就让测试用例可以运行在Spring环境中。
junit的入口也支持在测例前后去插入一些操作,自己去实现RunnerListener即可。junit默认实现了监听器去记录测例的耗时,失败的数量等信息。
我指定的Runner是SpringRunner,其与SpringJUnit4ClassRunner并没什么区别,可以看其实现完全继承了SpringJUnit4ClassRunner的实现。
所以我们直接看SpringJUnit4ClassRunner的runner实现:
它首先判断了当前的环境是否需要忽略单测。如果忽略会在通知里得到通知。关于环境的指定控制可以参考注解@IfProfileValue,判断环境正确之后继续调用junit包父类ParentRunner的方法执行。
其定义了执行的基本的模板:
classBlock里面定义线程池执行和测例执行的一些before和after逻辑,里面的runChild是抽象方法,也是留给各个Runner实现的钩子。getFilteredChildren能够根据@Test注解拿到所有需要运行的用例方法,然后每个方法去调用具体的Runner运行。
其流程图如下
SpringJUnit4ClassRunner的运行每个方法会给每个测例方法进行一个封装成Statement。关键就在methodBlock方法,它实现了Spring boot对方法运行的封装
createTest会在测试的上下文里维护一个配置,然后会用通知机制一样去依次调用需要准备的东西,其中就包含spring容器的上下文。
其会执行injectDependencies,处理依赖的bean准备。TestContext是每个单测方法需要运行的上下文,在Spring boot的测试环境下,其维护了Spring的上下文,每个方法的执行都会去获取Spring的上下文
根据单测的相关信息文获取上spring的上下文,为了避免每次都去加载容器,TestContext会维护一个spring容器的缓存,CacheAwareContextLoaderDelegate
CacheAwareContextLoaderDelegate其内部的获取又是通过单测配置信息去ContextCache获取的,其内部是一个同步SynchronizedMap去保存的。
其内部实现看测例的配置信息去获取加载过的容器,如果没获取到就会触发重新加载新容器的流程,所以关键就是看key在Map中的获取逻辑,其底层是spring test自己实现一个的Map
可以看到其是基于HashMap的一个哈希结构,根据Jdk的源码,我们可以知道HashMap的key是根据hashCode与equals去比较key,那可以确定,要想复用同一个容器就得看Key值的hashCode和equals实现。接下来我们看MergedContextConfiguration源码.
可以发现其比较的值是:
* @param testClass the test class for which the configuration was merged * @param locations the merged context resource locations * @param classes the merged annotated classes * @param contextInitializerClasses the merged context initializer classes * @param activeProfiles the merged active bean definition profiles * @param propertySourceLocations the merged {@code PropertySource} locations * @param propertySourceProperties the merged {@code PropertySource} properties * @param contextCustomizers the context customizers * @param contextLoader the resolved {@code ContextLoader} * @param cacheAwareContextLoaderDelegate a cache-aware context loader * delegate with which to retrieve the parent context * @param parent the parent configuration or {@code null} if there is no parent
这些参数确定了能否共享SpringApplication,那两个测试类一个@Autowired,另一个使用@MockBean,肯定是改变这里面某个值,我们可以回溯这个MergedContextConfiguration是在什么时候被初始化的。这个还要追溯到idea的启动类,找到Runner的时候,SpringJUnit4ClassRunner的初始化的过程。
在每个测试类的运行都会唤起SpringJUnit4ClassRunner初始化,调用构造函数的时候会去加载测试类的上下文
去创建这个TextContextManager
这里首先会根据被测试类的继承关系和注解的递归去找到固定包下面被注解@BootstrapWith修饰的类,因为是Spring boot test这里会根据@SpringBootTest 注解找到SpringBootTestContextBootstrapper类,找到这个引导类之后就会去初始化MergedContextConfiguration了。
引导类通过SPI机制加载到所有的Customizer,并根据需要DefinitionsParser,进行转换,保存在MergedContextConfiguration的一个字段,mock的一个属性会在转换的时候记录到,而非mock的contextCustomizers则不会记录。
注意这里提到的
两个类一个用mock的字段,一个用非mock的字段,两个MockitoContextCustomizer的definitions就不一样,因此无法共享上下文,因此需要重新启动一个Spring容器,并存放到CacheAwareContextLoaderDelegate,以便后面共享。
结论
分析源码的设计,发现应用了很多SPI与可扩展的设计,idea与junit的解耦,junit的抽象与模板定义与各个测试框架的扩展。
针对容器重启的角度,对于一个类来说,一定是共享一个spring上下文,但是不同的类可能由于注入的bean的方式不同导致无法共享spring上下文,所以导致重启会浪费掉一些时间,因此建议确定好mock的边界,对尽量多的测例共享一个容器视角可以提高单测效率,基于此可以设计多继承关系的单测结构,并把注入的bean向上共享,避免各个测试子类自己去注入出现不一致的情况。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持自由互联。