一文讀懂參數化測試與Spring test的完美結合(附源碼剖析)

本文使用的是JUnit4.12 Spring test5.0.7.RELEASE源碼java

前因

前不久遇到一個問題,一個Spring Boot項目的普通單元測試可以正常使用Spring test的特性,例如依賴注入、事務管理等,一旦使用JUnit4提供的@RunWith(Parameterized.class)參數化測試後,Spring test的特性便再也不適用。查遍網上的中文資料,都沒有給出完美的解決方案,更多的是簡單調用TestContextManager.prepareTestInstance()來實現Spring的初始化,沒有解決事務管理等Spring test特性不可用的問題。基於緣由,我決定好好研究一下JUnit4與Spring test的源碼,理解它們的實現原理、拓展機制,找到方法完全解決這類問題。node

以上問題能夠拆分紅四個子問題,分別是:spring

  • JUnit4內部是如何工做的
  • Spring test如何在JUnit4上拓展
  • Parameterized、Suite和BlockJUnit4ClassRunner有何異同
  • 如何讓參數化測試與Spring test完美結合

1. JUnit4內部是如何工做的

(1) 關鍵類的介紹
複製代碼

Runner 描述一個測試案例整體上該如何執行,其核心是run(RunNotifier)方法,其中RunNotifier用於發佈通知
ParentRunner 繼承自Runner,源碼中的註釋是這樣的設計模式

Provides most of the functionality specific to a Runner that implements a "parent node" in the test tree, with children defined by objects of some data type T. (For BlockJUnit4ClassRunner, T is Method . For Suite, T is Class.)數組

大意就是把測試案例構形成相似Tree的結構,child是泛型T,對於BlockJUnit4ClassRunner來講T是Method, 而Suite的T是Class
BlockJUnit4ClassRunner JUnit4的默認Runner,繼承自ParentRunner
Statement 描述一個JUnit4單元測試具體要作的事情,是JUnit4拓展的核心,它只有一個方法evaluate()
RunnerBuilder 描述如何構建一組Runner
JUnitCore JUnit4最開始啓動的地方框架

(2) 一個單元測試的運行過程
複製代碼

一般在IDE手動運行單元測試查看案例缺陷率,代碼覆蓋率都是由Eclipse/Intellij IDEA等IDE做爲主入口,而後調起run(Request)獲取該測試案例的Runneride

public Result run(Request request) {
    return run(request.getRunner());
}
複製代碼

隨後默認會以IgnoredBuilderAnnotatedBuilderSuiteMethodBuilderJunit3BuilderJunit4Builder的前後順序尋找適合的RunnerBuilderspring-boot

@Override
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;
}
複製代碼

IgnoredBuilder的優先級最高,尋找測試類是否有@Ignore註解,建立IgnoredClassRunner,該測試案例會被忽略
AnnotatedBuilder的優先級次高,咱們大多數測試案例都屬於這種狀況.它尋找測試類是否有@RunWith註解,反射調用(Class)構造方法,若是找不到則調用(Class,RunnerBuilder)構造方法單元測試

@Override
public Runner runnerForClass(Class<?> testClass) throws Exception {
    for (Class<?> currentTestClass = testClass; currentTestClass != null;
        currentTestClass = getEnclosingClassForNonStaticMemberClass(currentTestClass)) {
        RunWith annotation = currentTestClass.getAnnotation(RunWith.class);
        if (annotation != null) {
            return buildRunner(annotation.value(), testClass);
        }
    }
    return null;
}
public Runner buildRunner(Class<? extends Runner> runnerClass, Class<?> testClass) throws Exception {
    try {
        return runnerClass.getConstructor(Class.class).newInstance(testClass);
    } catch (NoSuchMethodException e) {
        try {
            return runnerClass.getConstructor(Class.class,
                RunnerBuilder.class).newInstance(testClass, suiteBuilder);
        } catch (NoSuchMethodException e2) {
            String simpleName = runnerClass.getSimpleName();
            throw new InitializationError(String.format(
                CONSTRUCTOR_ERROR_FORMAT, simpleName, simpleName));
        }
    }
}
複製代碼

再到SuiteMethodBuilder,尋找命名爲suite的method
Junit3Builder會判斷測試類是否TestCase的子類,這是爲了兼容JUnit3
若是以上條件都不符合,就會使用Junit4Builder,默認使用BlockJUnit4ClassRunner,所以若是咱們的測試案例沒有額外的註解,都是使用BlockJUnit4ClassRunner運行測試

肯定了Runner後,JUnitCore就執行run(Runner),調用Runner.run(Notifier)

public Result run(Runner runner) {
    Result result = new Result();
    RunListener listener = result.createListener();
    notifier.addFirstListener(listener);
    try {
        notifier.fireTestRunStarted(runner.getDescription());
        runner.run(notifier);
        notifier.fireTestRunFinished(result);
    } finally {
        removeListener(listener);
    }
    return result;
}
複製代碼

不一樣的Runner實現方式不一樣,下面以默認的BlockJUnit4ClassRunner爲例分析
BlockJUnit4ClassRunner沒有重寫run()方法,所以調用了父類ParentRunner.run()。 方法邏輯很簡單, classBlock()構建一個statement,而後執行statement的evaluate()

@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);
    }
}
複製代碼

咱們看一下classBlock

protected Statement classBlock(final RunNotifier notifier) {
    Statement statement = childrenInvoker(notifier);
    if (!areAllChildrenIgnored()) {
        statement = withBeforeClasses(statement);
        statement = withAfterClasses(statement);
        statement = withClassRules(statement);
    }
    return statement;
}
複製代碼

childrenInvoker()構造了一個statement,執行runChildren()方法,由於是ParentRunner,因此對它來講主要是runChildren(),至於withBeforeClasses(),withAfterClasses()的做用,咱們稍後再分析

private void runChildren(final RunNotifier notifier) {
    final RunnerScheduler currentScheduler = scheduler;
    try {
        for (final T each : getFilteredChildren()) {
            currentScheduler.schedule(new Runnable() {
                public void run() {
                    ParentRunner.this.runChild(each, notifier);
                }
            });
        }
    } finally {
        currentScheduler.finished();
    }
}
複製代碼

其邏輯也十分簡單,根據getChildren()獲取Children列表,而後逐個調用runChild()。getChildren()和runChild()在ParentRunner中都沒有實現,咱們來看一會兒類BlockJUnit4ClassRunner的實現

@Override
protected void runChild(final FrameworkMethod method, RunNotifier notifier) {
    Description description = describeChild(method);
    if (isIgnored(method)) {
        notifier.fireTestIgnored(description);
    } else {
        runLeaf(methodBlock(method), description, notifier);
    }
}
複製代碼

getChildren()尋找測試類中被@Test註解的方法
runChild()則是對每一個child(在BlockJUnit4ClassRunner中就是method)調用methodBlock()封裝,而後調用statement.evaluate(),執行整個測試方法

BlockJUnit4ClassRunner的運行過程以下所示

總的脈絡理清後,咱們來詳細分析一下classBlock()和methodBlock()。classBlock()在前文已經貼過代碼,此處再也不展現。methodBlock()的代碼以下

protected Statement methodBlock(FrameworkMethod method) {
    Object test;
    try {
        test = new ReflectiveCallable() {
            @Override
            protected Object runReflectiveCall() throws Throwable {
                return createTest();
            }
        }.run();
    } catch (Throwable e) {
        return new Fail(e);
    }

    Statement statement = methodInvoker(method, test);
    statement = possiblyExpectingExceptions(method, test, statement);
    statement = withPotentialTimeout(method, test, statement);
    statement = withBefores(method, test, statement);
    statement = withAfters(method, test, statement);
    statement = withRules(method, test, statement);
    return statement;
}
複製代碼

咱們能夠看到classBlock()和methodBlock()不停對statement進行包裝,上一個方法返回的statement做爲下一個方法的參數.這種設計模式與責任鏈,Struts2的攔截器十分類似,每一次包裝都會把參數statement做爲next,而後調用自身的邏輯 假設有一個測試類TestClass,他有兩個測試方法,分別是testMethodA()和testMethodB(),那麼這個測試類的運行圖以下所示

  • ClassRules對應@ClassRule註解
  • AfterClasses對應@AfterClass註解
  • BeforeClasses對應@BeforeClass註解
  • Rules對應@Rule註解
  • Afters對應@ClassRule註解
  • Beofres對應@ClassRule註解
  • PotentialTimeout對應@Test註解的timeout屬性
  • ExpectingExceptions對應@Test註解的expected屬性
  • methodInvoker對應反射調起的測試方法

這便徹底與咱們使用JUnit4的註解對應上了,至此,咱們已經瞭解JUnit4內部的工做原理

2. Spring test如何在JUnit4上拓展

那麼Spring做爲Java開發的核心框架,他是如何把自身的test特性拓展在JUnit4上?
關鍵在於TestContextManagerSpringJUnit4ClassRunner(或者叫作SpringRunner),後者繼承BlockJUnit4ClassRunner並重寫了部分方法

@Override
protected Statement methodBlock(FrameworkMethod frameworkMethod) {
   Object testInstance;
   try {
      testInstance = new ReflectiveCallable() {
        @Override
        protected Object runReflectiveCall() throws Throwable {
            return createTest();
        }
      }.run();
   }
   catch (Throwable ex) {
      return new Fail(ex);
   }
   Statement statement = methodInvoker(frameworkMethod, testInstance);
   statement = withBeforeTestExecutionCallbacks(frameworkMethod, testInstance, statement);
   statement = withAfterTestExecutionCallbacks(frameworkMethod, testInstance, statement);
   statement = possiblyExpectingExceptions(frameworkMethod, testInstance, statement);
   statement = withBefores(frameworkMethod, testInstance, statement);
   statement = withAfters(frameworkMethod, testInstance, statement);
   statement = withRulesReflectively(frameworkMethod, testInstance, statement);
   statement = withPotentialRepeat(frameworkMethod, testInstance, statement);
   statement = withPotentialTimeout(frameworkMethod, testInstance, statement);
   return statement;
}
複製代碼

咱們能夠看到他與BlockJUnit4ClassRunner.methodBlock()大體相同,區別在於他額外添加了BeforeTestExecution, AfterTestExecutionPotentialRepeat,前二者是Spring test自身添加的包裝點,不在JUnit4默認提供的包裝點中,後者是對@Repeat註解的支持
除此以外,Spring test還提供了TestExecutionListener監聽器,7個接口方法對應測試案例的7個包裝點

下面按照時間的先後順序簡單介紹

  • prepareTestInstance()對應實例初始化時的準備
  • beforeTestClass()處於@BeforeClass執行點以後
  • beforeTestMethod()處於@Before執行點以後, 如開啓事務等
  • beforeTestExecution()處於最接近測試方法執行前
  • afterTestExecution()處於ExpectException執行點以後,最接近測試方法執行後,
  • afterTestMethod()處於@After執行點以後, 如提交或回滾事務
  • afterTestClass()處於@AfterClass執行點以後

還有一點必需要提到的是,在SpringJUnit4ClassRunnerJUnitCore初始化的時候,會建立Spring的TestContextManager,他會找到classpath下META-INF/spring.factories中定義好的TestExecutionListener,Spring Boot項目一般會找到12個,分別在spring-test,spring-boot-test和spring-boot-test-autoconfigure三個jar包下,所以若是要根據項目需求自定義TestExecutionListener,只須要按照上述方式設計即可注入到測試案例的生命週期中

這12個TestExecutionListener中有一個 TransactionalTestExecutionListener,咱們測試中常用的@RollBack和@Commit註解就是他來實現的

@Override
public void beforeTestMethod(final TestContext testContext) throws Exception {
   Method testMethod = testContext.getTestMethod();
   Class<?> testClass = testContext.getTestClass();
   // 省略部分代碼
      tm = getTransactionManager(testContext, transactionAttribute.getQualifier());
      Assert.state(tm != null,
            () -> "Failed to retrieve PlatformTransactionManager for @Transactional test: " + testContext);
   }
   // 開啓事務
   if (tm != null) {
      txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext));
      runBeforeTransactionMethods(testContext);
      txContext.startTransaction();
      TransactionContextHolder.setCurrentTransactionContext(txContext);
   }
}
複製代碼

他實現了beforeTestMethod()和afterTestMethod(),在這兩個方法中開啓/提交/回滾事務

綜上, SpringJUnit4ClassRunner的一個statement有這麼多運行過程(我也很佩服我本身居然畫了出來)

3. Parameterized、Suite和BlockJUnit4ClassRunner有何異同

在JUnit4裏,@RunWith裏常常搭配BlockJUnit4ClassRunnerParameterized參數, BlockJUnit4ClassRunner在上文已經詳細介紹了,下面介紹一下Parameterized

Parameterized繼承自Suite,Suite用於多個測試類合在一塊兒跑,而Parameterized是一個測試類對多組參數執行屢次,與Suite本質上很相似,也就理解爲什麼Parameterized會繼承Suite了

Suite繼承自ParentRunner,注意不一樣於BlockJUnit4ClassRunner繼承自ParentRunner,說明Suite的children是Runner,比BlockJUnit4ClassRunner更高一級.所以Suite的getChildren()就是返回runners ,而runChild()就是對runner調用run()

@Override
protected List<Runner> getChildren() {
    return runners;
}
@Override
protected void runChild(Runner runner, final RunNotifier notifier) {
    runner.run(notifier);
}
複製代碼

Parameterized與Suite的差別在於,Suite的Children是測試類集合對應的Runner集合.
Parameterized根據有多少組參數化數組,就構建多少組BlockJUnit4ClassRunnerWithParameters,而後將每一組參數化數組注入到每個Runner中,以後就能夠像Suite同樣runChild()

4. 如何讓參數化測試與Spring test完美結合

結合上文描述的Spring testParameterized可知,參數化測試默認使用的Runner是BlockJUnit4ClassRunnerWithParameters,它繼承了BlockJUnit4ClassRunner實現了注入參數的功能,沒有Spring test的特性

在Parameterized的源碼註釋中有一段話給了咱們提示

By default the Parameterized runner creates a slightly modified BlockJUnit4ClassRunner for each set of parameters. You can build an own Parameterized runner that creates another runner for each set of parameters. Therefore you have to build a ParametersRunnerFactory that creates a runner for each TestWithParameters. (TestWithParameters are bundling the parameters and the test name.) The factory must have a public zero-arg constructor. Use the Parameterized.UseParametersRunnerFactory to tell the Parameterized runner that it should use your factory.

大意就是若是想自定義Parameterized的Runner,請從新實現ParametersRunnerFactory並構建一個能夠注入參數的Runner,而後用@ UseParametersRunnerFactory註解來指定自定義的工廠.

因此咱們只須要設計一個類SpringJUnit4ClassRunnerWithParametersFactory繼承SpringJUnit4ClassRunner,確保支持Spring test的特性,而後從新加入注入參數的功能就行了,這部分功能能夠參考BlockJUnit4ClassRunnerWithParameters來實現.

在實現的過程當中咱們發現SpringJUnit4ClassRunner和BlockJUnit4ClassRunnerWithParameters都重寫了createTest()方法,那咱們只需把兩個方法融合在一塊兒就行了 最後效果以下

@Override
public Object createTest() throws Exception {
    Object testInstance;
    if (fieldsAreAnnotated()) {
        testInstance = createTestUsingFieldInjection();
    } else {
        testInstance = createTestUsingConstructorInjection();
    }
    getTestContextManager().prepareTestInstance(testInstance);
    return testInstance;
}
複製代碼

固然這不是惟一的解決方法,Spring test提供了另一種更通用的解決方法,就是在本來BlockJUnit4ClassRunner的RulesClassRules處添加上Spring test的功能

具體操做就是在測試類加上如下代碼就行了

@ClassRule
    public static final SpringClassRule springClassRule = new SpringClassRule();
    @Rule
    public final SpringMethodRule springMethodRule = new SpringMethodRule();
複製代碼

但這種方法有個弊端,因爲TestExecutionListener接口定義了3套注入點,JUnit4只提供了2個注入的地方,對於before/afterTestExecution是沒法注入的,要千萬注意!

下面也是官方源碼中提醒咱們注意(吐槽JUnit4的缺點)

WARNING: Due to the shortcomings of JUnit rules, the SpringMethodRule does not support the beforeTestExecution() and afterTestExecution() callbacks of the TestExecutionListener API.

所幸的是,Spring test提供的12個TestExecutionListener都沒有使用before/afterTestExecution,因此那12個TestExecutionListener在這種方式下仍能正常運行

雜七雜八說點什麼

JUnit4和Spring test的源碼仍是比較容易理解的,不得不說,調試+堆棧+註釋真是理解源碼的三大法寶。固然了JUnit4和Spring test的內容不只僅是這些,本文只是將關鍵設計抽出來分析,技術就是一個越挖越深的巨坑啊

在閱讀源碼的時候還發現了一些有趣的地方, SpringJUnit4ClassRunner在加載的時候,會找出JUnit4的withRules方法,用反射改爲public,彷彿在吐槽JUnit4不把這個方法開放,但Spring偏要擴展這個方法

static {
   Assert.state(ClassUtils.isPresent("org.junit.internal.Throwables", SpringJUnit4ClassRunner.class.getClassLoader()),
         "SpringJUnit4ClassRunner requires JUnit 4.12 or higher.");
   Method method = ReflectionUtils.findMethod(SpringJUnit4ClassRunner.class, "withRules",
         FrameworkMethod.class, Object.class, Statement.class);
   Assert.state(method != null, "SpringJUnit4ClassRunner requires JUnit 4.12 or higher");
   ReflectionUtils.makeAccessible(method);
   withRulesMethod = method;
}
複製代碼

至於爲何會分析JUnit4,由於Spring-boot-starter-test默認就引入了JUnit4,我也不知道爲何不引入JUnit5.若是有小夥伴知道緣由麻煩私信留言告訴我.基於JUnit5的新特性,後續可能會使用它,若是到時候有新的發現會再寫一篇文章

感謝小夥伴耐心看完了整篇文章,若是以爲對你有一點幫助,不妨關注一下

相關文章
相關標籤/搜索