簡介: 單元測試是軟件開發過程中的重要一環,好的單測可以幫助我們更早的發現問題,爲系統的穩定運行提供保障。單測還是很好的說明文檔,我們往往看單測用例就能夠了解到作者對類的設計意圖。代碼重構時也離不開單測,豐富的單測用例會使我們重構代碼時信心滿滿。雖然單測如此重要,但是一直來都不是很清楚其運行原理,也不知道爲什麼要做這樣或那樣的配置,這樣終究是不行的,於是準備花時間探究下單測原理,並在此記錄。
前言
單元測試是軟件開發過程中的重要一環,好的單測可以幫助我們更早的發現問題,爲系統的穩定運行提供保障。單測還是很好的說明文檔,我們往往看單測用例就能夠了解到作者對類的設計意圖。代碼重構時也離不開單測,豐富的單測用例會使我們重構代碼時信心滿滿。雖然單測如此重要,但是一直來都不是很清楚其運行原理,也不知道爲什麼要做這樣或那樣的配置,這樣終究是不行的,於是準備花時間探究下單測原理,並在此記錄。
當在IDEA中Run單元測試時發生了什麼?
首先,來看一下當我們直接通過IDEA運行單例時,IDEA幫忙做了哪些事情:
- 將工程源碼和測試源碼進行編譯,輸出到了target目錄
- 通過java命令運行com.intellij.rt.junit.JUnitStarter,參數中指定了junit的版本以及單測用例名稱
java com.intellij.rt.junit.JUnitStarter -ideVersion5 -junit4 fung.MyTest,test
這裏着重追下JUnitStarter的代碼,該類在IDEA提供的junit-rt.jar插件包中,具體目錄:/Applications/IntelliJ IDEA.app/Contents/plugins/junit/lib/junit-rt.jar。可以將這個包引入到我們自己的工程項目中,方便閱讀源碼:
JUnitStarter的main函數
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框架,同時根據入參填充listeners
String agentName = processParameters(argList, listeners, name);
...
// 啓動測試
int exitCode = prepareStreamsAndStart(array, agentName, listeners, name[0]);
...
接下來看下prepareStreamsAndStart方法運行的時序圖,這裏以JUnit4爲例:
當IDEA確認好要啓動的框架版本後,會通過類的全限定名稱反射創建IdeaTestRunner<?>的實例。這裏以JUnit4爲例,IDEA會實例化com.intellij.junit4.JUnit4IdeaTestRunner類對象並調用其startRunnerWithArgs方法,在該方法中會通過buildRequest方法構建org.junit.runner.Request,通過getDescription方法獲取org.junit.runner.Description,最後創建org.junit.runner.JUnitCore實例並調用其run方法。
簡而言之就是,IDEA最終會藉助Junit4框架的能力啓動並運行單測用例,所以接下來有必要對Junit4框架的源碼做些深入的探究。
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));
}
@Test
public void test1() {
System.out.println("test1");
}
@Test
public void test2() {
System.out.println("test2");
}
@Test
public void test3() {
System.out.println("test3");
}
}
這裏我們不再通過IDEA的插件啓動單元測試,而是直接通過main函數,核心代碼如下:
public static void main(String[] args) {
// 1. 創建JUnitCore的實例
JUnitCore runner = new JUnitCore();
// 2. 通過單測類的Class對象構建Request
Request request = Request.aClass(MyTest.class);
// 3. 運行單元測試
Result result = runner.run(request.getRunner());
// 4. 打印結果
System.out.println(JSON.toJSONString(result));
}
着重看下runner.run(request.getRunner()),先看run函的代碼:
可以看到最終運行哪種類型的測試流程取決於傳入的runner實例,即不同的Runner決定了不同的運行流程,通過實現類的名字可以大概猜一猜,JUnit4ClassRunner應該是JUnit4基本的測試流程,MockitoJUnitRunner應該是引入了Mockito的能力,SpringJUnit4ClassRunner應該和Spring有些聯繫,可能會啓動Spring容器。
現在,我們回過頭來看看runner.run(request.getRunner())中request.getRunner()的代碼:
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;
}
可以看到Runner是基於傳入的測試類(testClass)的信息選擇的,這裏的規則如下:
- 如果解析失敗了,則返回ErrorReportingRunner
- 如果測試類上有@Ignore註解,則返回IgnoredClassRunner
- 如果測試類上有@RunWith註解,則使用@RunWith的值實例化一個Runner返回
- 如果canUseSuiteMethod=true,則返回SuiteMethod,其繼承自JUnit38ClassRunner,是比較早期的JUnit版本了
- 如果JUnit版本在4之前,則返回JUnit38ClassRunner
- 如果上面都不滿足,則返回BlockJUnit4ClassRunner,其表示的是一個標準的JUnit4測試模型
我們先前舉的那個簡單的例子返回的就是BlockJUnit4ClassRunner,那麼就以BlockJUnit4ClassRunner爲例,看下它的run方法是怎麼執行的吧。
首先會先走到其父類ParentRunner中的run方法
@Override
public 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);
}
}
這裏有必要展開說下Statement,官方的解釋是:Represents one or more actions to be taken at runtime in the course of running a JUnit test suite.
Statement可以簡單理解爲對可執行方法的封裝和抽象,如RunBefores就是一個Statement,它封裝了所有標記了@BeforeClass註解的方法,在運行單例類的用例之前會執行這些方法,運行完後RunBefores還會通過next.evaluate()運行後續的Statement。這裏列舉一下常見的Statement:
- RunBefores,會先運行befores裏封裝的方法(一般是標記了@BeforeClass或@Before),再運行next.evaluate()
- RunAfters,會先運行next.evaluate(),再運行afters裏封裝的方法(一般是標記了@AfterClass或@After)
- InvokeMethod,直接運行testMethod中封裝的方法
由此可見,整個單測的運行過程,實際上就是一系列Statement的運行過程,以之前的MyTest爲例,它的Statement的執行過程大致可以概況如下:
還剩一個最後問題,實際被測試方法是如何被運行的呢?答案是反射調用。核心代碼如下:
@Override
public void evaluate() throws Throwable {
testMethod.invokeExplosively(target);
}
public Object invokeExplosively(final Object target, final Object... params)
throws Throwable {
return new ReflectiveCallable() {
@Override
protected Object runReflectiveCall() throws Throwable {
return method.invoke(target, params);
}
}.run();
}
至此一個標準Junit4的單測用例的執行過程就分析完了,那麼像Spring這種需要起容器的單測又是如何運行的呢?接下來就來探究一下。
Spring單測的探究
我們還是以一個簡單的例子開始吧
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = { "/spring/spring-mybeans.xml" })
public class SpringRunnerTest {
@Autowired
private MyTestBean myTestBean;
@Test
public void test() {
myTestBean.test();
}
}
這裏先粗濾的概括下運行單測時發生了什麼。首先,@RunWith註解了該測試類,所以Junit框架會先用SpringRunnerTest.class作爲參數創建SpringRunner的實例,然後調用SpringRunner的run方法運行測試,該方法中會啓動Spring容器,加載@ContextConfiguration註解指定的Bean配置文件,同時也會處理@Autowired註解爲SpringRunnerTest的實例注入myTestBean,最後運行test()測試用例。
簡言之就是先通過SpringRunner啓動Spring容器,然後運行測試方法。接下來探究一下SpringRunner啓動Spring容器的過程。
public final class SpringRunner extends SpringJUnit4ClassRunner {
public SpringRunner(Class<?> clazz) throws InitializationError {
super(clazz);
}
}
public class SpringJUnit4ClassRunner extends BlockJUnit4ClassRunner {
...
}
SpringRunner和SpringJUnit4ClassRunner實際是等價的,可以認爲SpringRunner是SpringJUnit4ClassRunner的一個別名,這裏着重看下SpringJUnit4ClassRunner類的實現。
SpringJUnit4ClassRunner繼承了BlockJUnit4ClassRunner,前面着重分析過BlockJUnit4ClassRunner,它運行的是一個標準的JUnit4測試模型,SpringJUnit4ClassRunner則是在此基礎上做了一些擴展,擴展的內容主要包括:
- 擴展了構造函數,多創建了一個TestContextManager實例。
- 擴展了createTest()方法,會額外調用TestContextManager的prepareTestInstance方法。
- 擴展了beforeClass,在執行@BeforeClass註解的方法前,會先調用TestContextManager的beforeTestClass方法。
- 擴展了before,在執行@Before註解的方法前,會先調用TestContextManager的beforeTestMethod方法。
- 擴展了afterClass,在執行@AfterClass註解的方法之後,會再調用TestContextManager的afterTestClass方法。
- 擴展了after,在執行@After註解的方法之後,會再調用TestContextManager的after方法。
TestContextManager是Spring測試框架的核心類,官方的解釋是:TestContextManager is the main entry point into the Spring TestContext Framework. Specifically, a TestContextManager is responsible for managing a single TestContext.
TestContextManager管理着TestContext,而TestContext則是對ApplicationContext的一個再封裝,可以把TestContext理解爲增加了測試相關功能的Spring容器。 TestContextManager同時也管理着TestExecutionListeners,這裏使用觀察者模式提供了對測試運行過程中的關鍵節點(如beforeClass, afterClass等)的監聽能力。
所以通過研究TestContextManager,TestContext和TestExecutionListeners的相關實現類的代碼,就不難發現測試時Spring容器的啓動祕密了。關鍵代碼如下:
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;
}
...
}
在DefaultTestContext的getApplicationContext方法中,調用了cacheAwareContextLoaderDelegate的loadContext,最終輾轉調到Context的refresh方法,從而構築起Spring容器上下文。時序圖如下:
那麼getApplicationContext方法又是在哪裏被調用的呢?
前面介紹過,TestContextManager擴展了createTest()方法,會額外調用其prepareTestInstance方法。
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);
}
}
}
prepareTestInstance方法中會調用所有TestExecutionListener的prepareTestInstance方法,其中有一個叫做DependencyInjectionTestExecutionListener的監聽器會調到TestContext的getApplicationContext方法。
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);
}
還剩最後一個問題,DependencyInjectionTestExecutionListener是如何被添加的呢?答案是spring.factories
至此Spring單測的啓動過程就探究明白了,接下來看下SpringBoot的。
SpringBoot單測的探究
一個簡單的SpringBoot單測例子
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class MySpringBootTest {
@Autowired
private MyTestBean myTestBean;
@Test
public void test() {
myTestBean.test();
}
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
public @interface SpringBootTest {
...
}
粗濾說明一下,這裏還是通過SpringRunner的run方法啓動測試,其中會啓動Spring容器,而@SpringBootTest則提供了啓動類,同時通過@BootstrapWith提供的SpringBootTestContextBootstrapper類豐富了TestContext的能力,使得其支持了SpringBoot的一些特性。這裏着重探究下@BootstrapWith註解以及SpringBootTestContextBootstrapper。
前面在介紹TestContextManager時,並沒有講到其構造函數以及TestContext的實例化過程,這裏將其補上
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());
}
...
}
構建DefaultTestContext需要傳3個參數:
- testClass,被測試的類元數據
- MergedContextConfiguration,封裝了聲明在測試類上的與測試容器相關的註解,如@ContextConfiguration, @ActiveProfiles, @TestPropertySource
- CacheAwareContextLoaderDelegate,用來loading或closing容器
那麼當我們需要擴展TestContext的功能,或者不想用DefaultTestContext時,應該怎麼辦呢?最簡單的方式自然是新寫一個類實現TestContextBootstrapper接口,並覆寫buildTestContext()方法,那麼如何告訴測試框架要使用新的實現類呢?@BootstrapWith就派上用場了。這裏來看下BootstrapUtils.resolveTestContextBootstrapper的代碼
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的處理
接下來看下SpringBootContextLoader的相關代碼
public class SpringBootContextLoader extends AbstractContextLoader {
@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();
// 設置mainApplicationClass
application.setMainApplicationClass(config.getTestClass());
// 設置primarySources
application.addPrimarySources(Arrays.asList(configClasses));
// 添加configLocations
application.getSources().addAll(Arrays.asList(configLocations));
// 獲取environment
ConfigurableEnvironment 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);
// 獲取並設置initializers
List<ApplicationContextInitializer<?>> initializers = getInitializers(config,