單元測試運行原理探究

簡介: 單元測試是軟件開發過程中的重要一環,好的單測可以幫助我們更早的發現問題,爲系統的穩定運行提供保障。單測還是很好的說明文檔,我們往往看單測用例就能夠了解到作者對類的設計意圖。代碼重構時也離不開單測,豐富的單測用例會使我們重構代碼時信心滿滿。雖然單測如此重要,但是一直來都不是很清楚其運行原理,也不知道爲什麼要做這樣或那樣的配置,這樣終究是不行的,於是準備花時間探究下單測原理,並在此記錄。

前言

單元測試是軟件開發過程中的重要一環,好的單測可以幫助我們更早的發現問題,爲系統的穩定運行提供保障。單測還是很好的說明文檔,我們往往看單測用例就能夠了解到作者對類的設計意圖。代碼重構時也離不開單測,豐富的單測用例會使我們重構代碼時信心滿滿。雖然單測如此重要,但是一直來都不是很清楚其運行原理,也不知道爲什麼要做這樣或那樣的配置,這樣終究是不行的,於是準備花時間探究下單測原理,並在此記錄。

當在IDEA中Run單元測試時發生了什麼?

首先,來看一下當我們直接通過IDEA運行單例時,IDEA幫忙做了哪些事情:

  1. 將工程源碼和測試源碼進行編譯,輸出到了target目錄
  2. 通過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)的信息選擇的,這裏的規則如下:

  1. 如果解析失敗了,則返回ErrorReportingRunner
  2. 如果測試類上有@Ignore註解,則返回IgnoredClassRunner
  3. 如果測試類上有@RunWith註解,則使用@RunWith的值實例化一個Runner返回
  4. 如果canUseSuiteMethod=true,則返回SuiteMethod,其繼承自JUnit38ClassRunner,是比較早期的JUnit版本了
  5. 如果JUnit版本在4之前,則返回JUnit38ClassRunner
  6. 如果上面都不滿足,則返回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則是在此基礎上做了一些擴展,擴展的內容主要包括:

  1. 擴展了構造函數,多創建了一個TestContextManager實例。
  2. 擴展了createTest()方法,會額外調用TestContextManager的prepareTestInstance方法。
  3. 擴展了beforeClass,在執行@BeforeClass註解的方法前,會先調用TestContextManager的beforeTestClass方法。
  4. 擴展了before,在執行@Before註解的方法前,會先調用TestContextManager的beforeTestMethod方法。
  5. 擴展了afterClass,在執行@AfterClass註解的方法之後,會再調用TestContextManager的afterTestClass方法。
  6. 擴展了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的能力,這些擴展主要包括:

  1. 將ContextLoader替換爲了SpringBootContextLoader
  2. 增加了DefaultTestExecutionListenersPostProcessor對TestExecutionListener進行增強處理
  3. 增加了對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,
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章