单元测试运行原理探究
前言
单元测试是软件开发过程中的重要一环,好的单测可以帮助我们更早的发现问题,为系统的稳定运行提供保障。单测还是很好的说明文档,我们往往看单测用例就能够了解到作者对类的设计意图。代码重构时也离不开单测,丰富的单测用例会使我们重构代码时信心满满。虽然单测如此重要,但是一直来都不是很清楚其运行原理,也不知道为什么要做这样或那样的配置,这样终究是不行的,于是准备花时间探究下单测原理,并在此记录。
当在IDEA中Run单元测试时发生了什么?
- 将工程源码和测试源码进行编译,输出到了target目录
- 通过java命令运行com.intellij.rt.junit.JUnitStarter,参数中指定了junit的版本以及单测用例名称
这里着重追下JUnitStarter的代码,该类在IDEA提供的junit-rt.jar插件包中,具体目录:/Applications/IntelliJ IDEA.app/Contents/plugins/junit/lib/junit-rt.jar。可以将这个包引入到我们自己的工程项目中,方便阅读源码:java com.intellij.rt.junit.JUnitStarter -ideVersion5 -junit4 fung.MyTest,test
public static void main(String[] args) {List<String> argList = new ArrayList(Arrays.asList(args));ArrayList<String> listeners = new ArrayList();String[] name = new String[1];String agentName = processParameters(argList, listeners, name);if (!"com.intellij.junit5.JUnit5IdeaTestRunner".equals(agentName) && !canWorkWithJUnitVersion(System.err, agentName)) {System.exit(-3);}if (!checkVersion(args, System.err)) {System.exit(-3);}String[] array = (String[])argList.toArray(new String[0]);int exitCode = prepareStreamsAndStart(array, agentName, listeners, name[0]);System.exit(exitCode);}
...// 处理参数,主要用来确定使用哪个版本的junit框架,同时根据入参填充listenersString agentName = processParameters(argList, listeners, name);...// 启动测试int exitCode = prepareStreamsAndStart(array, agentName, listeners, name[0]);...
Junit4源码探究 Junit是一个由Java语言编写的单元测试框架,已在业界被广泛运用,其作者是大名鼎鼎的Kent Beck和Erich Gamma,前者是《重构:改善既有代码的设计》和《测试驱动开发》的作者,后者则是《设计模式》的作者,Eclipse之父。Junit4发布于2006年,虽然是老古董了,但其中所蕴含的设计理念和思想却并不过时,有必要认真探究一番。 首先我们还是从一个简单的单测用例开始:
public class MyTest {public static void main(String[] args) {JUnitCore runner = new JUnitCore();Request request = Request.aClass(MyTest.class);Result result = runner.run(request.getRunner());System.out.println(JSON.toJSONString(result));}@Testpublic void test1() {System.out.println("test1");}@Testpublic void test2() {System.out.println("test2");}@Testpublic void test3() {System.out.println("test3");}}
public static void main(String[] args) {// 1. 创建JUnitCore的实例JUnitCore runner = new JUnitCore();// 2. 通过单测类的Class对象构建RequestRequest request = Request.aClass(MyTest.class);// 3. 运行单元测试Result result = runner.run(request.getRunner());// 4. 打印结果System.out.println(JSON.toJSONString(result));}
public Runner getRunner() {if (runner == null) {synchronized (runnerLock) {if (runner == null) {runner = new AllDefaultPossibilitiesBuilder(canUseSuiteMethod).safeRunnerForClass(fTestClass);}}}return runner;}
public Runner safeRunnerForClass(Class<?> testClass) {try {return runnerForClass(testClass);} catch (Throwable e) {return new ErrorReportingRunner(testClass, e);}}
public Runner runnerForClass(Class<?> testClass) throws Throwable {List<RunnerBuilder> builders = Arrays.asList(ignoredBuilder(),annotatedBuilder(),suiteMethodBuilder(),junit3Builder(),junit4Builder());for (RunnerBuilder each : builders) {Runner runner = each.safeRunnerForClass(testClass);if (runner != null) {return runner;}}return null;}
- 如果解析失败了,则返回ErrorReportingRunner
- 如果测试类上有@Ignore注解,则返回IgnoredClassRunner
- 如果测试类上有@RunWith注解,则使用@RunWith的值实例化一个Runner返回
- 如果canUseSuiteMethod=true,则返回SuiteMethod,其继承自JUnit38ClassRunner,是比较早期的JUnit版本了
- 如果JUnit版本在4之前,则返回JUnit38ClassRunner
- 如果上面都不满足,则返回BlockJUnit4ClassRunner,其表示的是一个标准的JUnit4测试模型
@Overridepublic void run(final RunNotifier notifier) {EachTestNotifier testNotifier = new EachTestNotifier(notifier,getDescription());try {Statement statement = classBlock(notifier);statement.evaluate();} catch (AssumptionViolatedException e) {testNotifier.addFailedAssumption(e);} catch (StoppedByUserException e) {throw e;} catch (Throwable e) {testNotifier.addFailure(e);}}
- RunBefores,会先运行befores里封装的方法(一般是标记了@BeforeClass或@Before),再运行next.evaluate()
- RunAfters,会先运行next.evaluate(),再运行afters里封装的方法(一般是标记了@AfterClass或@After)
- InvokeMethod,直接运行testMethod中封装的方法
@Overridepublic void evaluate() throws Throwable {testMethod.invokeExplosively(target);}
public Object invokeExplosively(final Object target, final Object... params)throws Throwable {return new ReflectiveCallable() {@Overrideprotected Object runReflectiveCall() throws Throwable {return method.invoke(target, params);}}.run();}
Spring单测的探究
我们还是以一个简单的例子开始吧
@RunWith(SpringRunner.class)@ContextConfiguration(locations = { "/spring/spring-mybeans.xml" })public class SpringRunnerTest {@Autowiredprivate MyTestBean myTestBean;@Testpublic void test() {myTestBean.test();}}
public final class SpringRunner extends SpringJUnit4ClassRunner {public SpringRunner(Class<?> clazz) throws InitializationError {super(clazz);}}
public class SpringJUnit4ClassRunner extends BlockJUnit4ClassRunner {...}
- 扩展了构造函数,多创建了一个TestContextManager实例。
- 扩展了createTest()方法,会额外调用TestContextManager的prepareTestInstance方法。
- 扩展了beforeClass,在执行@BeforeClass注解的方法前,会先调用TestContextManager的beforeTestClass方法。
- 扩展了before,在执行@Before注解的方法前,会先调用TestContextManager的beforeTestMethod方法。
- 扩展了afterClass,在执行@AfterClass注解的方法之后,会再调用TestContextManager的afterTestClass方法。
- 扩展了after,在执行@After注解的方法之后,会再调用TestContextManager的after方法。
public class DefaultTestContext implements TestContext {...public ApplicationContext getApplicationContext() {ApplicationContext context = this.cacheAwareContextLoaderDelegate.loadContext(this.mergedContextConfiguration);if (context instanceof ConfigurableApplicationContext) {@SuppressWarnings("resource")ConfigurableApplicationContext cac = (ConfigurableApplicationContext) context;Assert.state(cac.isActive(), () ->"The ApplicationContext loaded for [" + this.mergedContextConfiguration +"] is not active. This may be due to one of the following reasons: " +"1) the context was closed programmatically by user code; " +"2) the context was closed during parallel test execution either " +"according to @DirtiesContext semantics or due to automatic eviction " +"from the ContextCache due to a maximum cache size policy.");}return context;}...}
public void prepareTestInstance(Object testInstance) throws Exception {if (logger.isTraceEnabled()) {logger.trace("prepareTestInstance(): instance [" + testInstance + "]");}getTestContext().updateState(testInstance, null, null);for (TestExecutionListener testExecutionListener : getTestExecutionListeners()) {try {testExecutionListener.prepareTestInstance(getTestContext());}catch (Throwable ex) {if (logger.isErrorEnabled()) {logger.error("Caught exception while allowing TestExecutionListener [" + testExecutionListener +"] to prepare test instance [" + testInstance + "]", ex);}ReflectionUtils.rethrowException(ex);}}}
public void prepareTestInstance(TestContext testContext) throws Exception {if (logger.isDebugEnabled()) {logger.debug("Performing dependency injection for test context [" + testContext + "].");}injectDependencies(testContext);}
protected void injectDependencies(TestContext testContext) throws Exception {Object bean = testContext.getTestInstance();Class<?> clazz = testContext.getTestClass();// 这里调用TestContext的getApplicationContext方法,构建Spring容器AutowireCapableBeanFactory beanFactory = testContext.getApplicationContext().getAutowireCapableBeanFactory();beanFactory.autowireBeanProperties(bean, AutowireCapableBeanFactory.AUTOWIRE_NO, false);beanFactory.initializeBean(bean, clazz.getName() + AutowireCapableBeanFactory.ORIGINAL_INSTANCE_SUFFIX);testContext.removeAttribute(REINJECT_DEPENDENCIES_ATTRIBUTE);}
SpringBoot单测的探究
一个简单的SpringBoot单测例子
@RunWith(SpringRunner.class)@SpringBootTest(classes = Application.class)public class MySpringBootTest {@Autowiredprivate MyTestBean myTestBean;@Testpublic void test() {myTestBean.test();}}
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Inherited@BootstrapWith(SpringBootTestContextBootstrapper.class)public @interface SpringBootTest {...}
public TestContextManager(Class<?> testClass) {this(BootstrapUtils.resolveTestContextBootstrapper(BootstrapUtils.createBootstrapContext(testClass)));}
public TestContextManager(TestContextBootstrapper testContextBootstrapper) {this.testContext = testContextBootstrapper.buildTestContext();registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());}
public abstract class AbstractTestContextBootstrapper implements TestContextBootstrapper {...public TestContext buildTestContext() {return new DefaultTestContext(getBootstrapContext().getTestClass(), buildMergedContextConfiguration(),getCacheAwareContextLoaderDelegate());}...}
- testClass,被测试的类元数据
- MergedContextConfiguration,封装了声明在测试类上的与测试容器相关的注解,如@ContextConfiguration, @ActiveProfiles, @TestPropertySource
- CacheAwareContextLoaderDelegate,用来loading或closing容器
static TestContextBootstrapper resolveTestContextBootstrapper(BootstrapContext bootstrapContext) {Class<?> testClass = bootstrapContext.getTestClass();Class<?> clazz = null;try {clazz = resolveExplicitTestContextBootstrapper(testClass);if (clazz == null) {clazz = resolveDefaultTestContextBootstrapper(testClass);}if (logger.isDebugEnabled()) {logger.debug(String.format("Instantiating TestContextBootstrapper for test class [%s] from class [%s]",testClass.getName(), clazz.getName()));}TestContextBootstrapper testContextBootstrapper =BeanUtils.instantiateClass(clazz, TestContextBootstrapper.class);testContextBootstrapper.setBootstrapContext(bootstrapContext);return testContextBootstrapper;}...}
private static Class<?> resolveExplicitTestContextBootstrapper(Class<?> testClass) {Set<BootstrapWith> annotations = AnnotatedElementUtils.findAllMergedAnnotations(testClass, BootstrapWith.class);if (annotations.isEmpty()) {return null;}if (annotations.size() == 1) {return annotations.iterator().next().value();}// 获取@BootstrapWith注解的值BootstrapWith bootstrapWith = testClass.getDeclaredAnnotation(BootstrapWith.class);if (bootstrapWith != null) {return bootstrapWith.value();}throw new IllegalStateException(String.format("Configuration error: found multiple declarations of @BootstrapWith for test class [%s]: %s",testClass.getName(), annotations));}
这里会通过@BootstrapWith注解的值,实例化定制的TestContextBootstrapper,从而提供定制的TestContext
SpringBootTestContextBootstrapper就是TestContextBootstrapper的实现类,它通过间接继承AbstractTestContextBootstrapper类扩展了创建TestContext的能力,这些扩展主要包括:
- 将ContextLoader替换为了SpringBootContextLoader
- 增加了DefaultTestExecutionListenersPostProcessor对TestExecutionListener进行增强处理
- 增加了对webApplicationType的处理
public class SpringBootContextLoader extends AbstractContextLoader {@Overridepublic 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();// 设置mainApplicationClassapplication.setMainApplicationClass(config.getTestClass());// 设置primarySourcesapplication.addPrimarySources(Arrays.asList(configClasses));// 添加configLocationsapplication.getSources().addAll(Arrays.asList(configLocations));// 获取environmentConfigurableEnvironment environment = getEnvironment();if (!ObjectUtils.isEmpty(config.getActiveProfiles())) {setActiveProfiles(environment, config.getActiveProfiles());}ResourceLoader resourceLoader = (application.getResourceLoader() != null)? application.getResourceLoader(): new DefaultResourceLoader(getClass().getClassLoader());TestPropertySourceUtils.addPropertiesFilesToEnvironment(environment,resourceLoader, config.getPropertySourceLocations());TestPropertySourceUtils.addInlinedPropertiesToEnvironment(environment,getInlinedProperties(config));application.setEnvironment(environment);// 获取并设置initializersList<ApplicationContextInitializer<?>> initializers = getInitializers(config,application);if (config instanceof WebMergedContextConfiguration) {application.setWebApplicationType(WebApplicationType.SERVLET);if (!isEmbeddedWebEnvironment(config)) {new WebConfigurer().configure(config, application, initializers);}}else if (config instanceof ReactiveWebMergedContextConfiguration) {application.setWebApplicationType(WebApplicationType.REACTIVE);if (!isEmbeddedWebEnvironment(config)) {new ReactiveWebConfigurer().configure(application);}}else {application.setWebApplicationType(WebApplicationType.NONE);}application.setInitializers(initializers);// 运行SpringBoot应用return application.run();}}
Maven插件如何运行单测
我们知道maven是通过一系列的插件帮助我们完成项目开发过程中的构建、测试、打包、部署等动作的,当在Console中运行maven clean test命令时,maven会依次运行以下goal:
- maven-clean-plugin:2.5:clean,用于清理target目录
- maven-resources-plugin:2.6:resources,将主工程目录下的资源文件移动到target目录下的classes目录中
- maven-compiler-plugin:3.1:compile,将主工程目录下的java源码编译为字节码,并移动到target目录下的classes目录中
- maven-resources-plugin:2.6:testResources,将测试工程目录下的资源文件移动到target目录下的test-classes目录中
- maven-compiler-plugin:3.1:testCompile,将测试工程目录下的java源码编译为字节码,并移动到target目录下的classes目录中
- maven-surefire-plugin:2.12.4:test,运行单测
<dependency><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>2.9</version></dependency><dependency><groupId>org.apache.maven.surefire</groupId><artifactId>surefire-junit4</artifactId><version>3.0.0-M7</version></dependency>
private static void execute( Class<?> testClass, Notifier notifier, Filter filter ){final int classModifiers = testClass.getModifiers();if ( !isAbstract( classModifiers ) && !isInterface( classModifiers ) ){Request request = aClass( testClass );if ( filter != null ){request = request.filterWith( filter );}Runner runner = request.getRunner();if ( countTestsInRunner( runner.getDescription() ) != 0 ){runner.run( notifier );}}}
总结
至此单元测试运行的相关原理就探究完了,我们来回顾下有哪些内容吧
- 通过IDEA直接运行单测时,会通过JUnitStarter的main方法作为入口,最终调用Junit运行单元测试。
- Junit4将@Before、@Test、@After这些注解打标的方法都抽象成了Statement,整个单测的运行过程,实际上就是一系列Statement的运行过程。方法的调用是通过反射的方式实现的。
- 借助于@RunWith(SpringRunner.class)注解,测试框架会运行SpringRunner实例的run方法,通过TestContextManager创建TestContext,并启动Spring容器。SpringRunner和SpringJUnit4ClassRunner实际上是等价的。
- 借助于@SpringBootTest和@BootstrapWith(SpringBootTestContextBootstrapper.class)注解,测试框架通过SpringBootTestContextBootstrapper增强了TestContext,达到了启动SpringBoot应用的目的。
- Maven通过运行maven-surefire-plugin:2.12.4:test启动单元测试,其核心是通过JUnit4Provider调用了JUnit框架的代码。
推荐阅读
3. 如何做好“防御性编码”?
发布你对ModelScope开源模型社区的体验评测,免费使用模型库搭建属于你的小应用,有机会获得AirPods和阿里云定制礼品等。
点击阅读原文查看详情。