TDD及單元測試最佳實踐

JavaDog發表於2019-03-04

TDD:What?Why?How?

TDD(測試驅動開發)既是一種軟體開發技術,也是一種設計方法論。其基本思想是通過測試來推動整個開發的進行,但測試驅動開發並不只是單純的測試工作,而是把需求分析、設計、質量控制量化的過程。

為什麼要採用TDD呢?TDD有如下幾點優勢:

  1. 在開發的過程中,把大的功能塊拆分成小的功能塊進行測試,降低複雜性,幫助我們小步快跑前進。
  2. 遵循“keep it simple, stupid”(KISS)和“You aren't gonna need it”(YAGNI)原則,只寫通過測試的必要程式碼,所以程式碼通常精簡清晰(clean and clear)。
  3. 由於寫測試用例實際上在模仿使用者,所以可以提升程式碼結構和介面設計的合理性。
  4. 儘早的暴露問題並解決,減小後續測試成本,長遠的看還可以最大限度規避線上故障。
  5. 測試程式碼即文件,測試程式碼中的用例、入參、預期結果是對程式碼最好的解釋。

TDD的基本生命週期如下圖:
TDD 生命週期

  1. 當一個需求來的時候,我們首先要做的就是增加一個測試或者重寫當前的相關測試。這個過程中,我們需要非常清楚的瞭解需求本質,反映在測試用例上,就是測試的輸入是什麼,得到的輸出是什麼。而測試資料也需要儘量包括真實資料和邊界資料。
  2. 執行測試,預期中,這個測試會失敗,因為相關功能還沒有被我們加在程式碼中。
  3. 編寫相關功能的程式碼,從而讓測試通過。
  4. 重新執行測試,這時候不僅要看第一步中的測試有沒有通過,還需要看以前通過的測試有沒有fail。如果測試失敗,那麼需要重寫編寫程式碼或者更新相關測試。
  5. 重構程式碼,為了讓新增的測試通過,不免會堆積程式碼,所以要時候保持重構,去除程式碼中的“bad smell”。

下面將用我們重構中的一個簡單的案例來展示TDD的過程。我們需要一個工具類來實現一個方法根據商品的tag判斷一個商品是否是批發商品:

  1. 明確需求和測試用例
    批發商品的tag為Long型的10000L,傳入的商品tags為一個String,以逗號分隔的各個商品tag,比如"10000, 12345"

    我們的測試用例為如下幾個:

    入參結果
    ""false
    "12345"false
    "10000"true
    "12345,10000,20000"true
    "&^837,20000,10000"true
  2. 實現方法
    我們的測試為:

    @DataProvider(name="isWholesaleProductDp")
    public Object[][] isWholesaleProductDp() {
        return new Object[][] {
            {"", false},
            {"12345", false},
            {"10000", true},
            {"12345,10000,20000", true},
            {"&^837,20000,10000", true},
        };
    }
    
    @Test(dataProvider = "isWholesaleProductDp")
    public void testIsWholesaleProduct(String productTags, boolean expected) {
        Assert.assertEquals(expected, ProductExtendsUtil.isWholesaleProduct(productTags));
    }
    複製程式碼

(1)第一個cycle 首先實現方法如下:

public boolean isWholesaleProduct(String productTags) {
    return true;
}
複製程式碼

很顯然前兩個用例會失敗。
(2)第二個cycle
我們需要編寫讓前兩個用例成功的程式碼:

public boolean isWholesaleProduct(String productTags) {
    if (StringUtils.isBlank(productTags)) {
        return false;
    }
    Set<Long> tagIdSet = Arrays.stream(productTags.split(Constant.COMMA)).filter(s -> StringUtils.isNotBlank(s)).map(Long :: valueOf).collect(
            Collectors.toSet());
    return CollectionUtils.isNotEmpty(tagIds) && tagIds.contains(WHOLESALE_TAG_ID);
}
複製程式碼

此時再執行單元測試,所有測試用例都通過。
3. 重構
考慮到以後我們不僅要判斷這個商品是否是批發品,還需要判斷其是否是其他型別的商品,於是重構將主要的判斷邏輯拆出來單獨成為一個函式:

public boolean containsTag(String productTags, Long tagId) {
    if (StringUtils.isBlank(productTags)) {
        return false;
    }
    Set<Long> tagIdSet = Arrays.stream(productTags.split(Constant.COMMA)).filter(s -> StringUtils.isNotBlank(s)).map(Long :: valueOf).collect(
            Collectors.toSet());
    return CollectionUtils.isNotEmpty(tagIds) && tagIds.contains(WHOLESALE_TAG_ID);
}
複製程式碼

以上就是TDD的基本過程,但在實際操作過程,對於一些簡單的方法實現,可以跳過一些步驟直接實現。

UTDD(單元測試驅動開發)

作為開發者(Developer),需要單獨完成的就是單元測試驅動開發。因為ATTD(Acceptance Test Driven Development,驗收驅動測試開發)通常需要QA同學介入。下面會針對Java單元測試的框架及技術展開。

1. 單元測試核心原則

單元測試需要遵循如下幾大核心原則:

  • 自動化:單元測試應該是全自動執行的,並且非互動式的。利用斷言Assert進行結果驗證。
  • 獨立性:保持單元測試的獨立性。為了保證單元測試穩定可靠且便於維護,單元測試用例之間決不能互相呼叫,也不能依賴執行的先後次序。 單測不負責檢查跨類或者跨系統的互動邏輯,那是整合測試的領域。
  • 可重複:單元測試是可以重複執行的,不能受到外界環境的影響。如果單測對外部環境(網路、服務、中介軟體等)有依賴,容易導致持續整合機制的不可用。
  • 全面性:除了正確的輸入得到預期的結果,還需要強制錯誤資訊輸入得到預期的結果,為了系統的魯棒性,應加入邊界值測試,包括迴圈邊界、特殊取值、特殊時間點、資料順序等。
  • 細粒度:保證測試粒度足夠小,有助於精確定位問題。單測粒度至多是類級別,一般是方法級別。單測不負責檢查跨類或者跨系統的互動邏輯,那是整合測試的領域。

2. 測試框架

在Java生態系統中,JUnit和TestNG是最受歡迎的兩個單元測試框架。JUnit最早由TDD的先驅Ken BeckErich Gamma開發,後來由JUnit團隊開發維護,截止到本文寫作時間已釋出JUnit 5。TestNG作為後起之秀,在JUnit的功能之外提供了一些獨特的功能。下面將結合一些程式碼案例對兩個框架的基本功能進行對比,其中JUnit將集中關注JUnit5中的功能。

總體架構

ut架構一個完整的測試平臺有以下幾個部分組成:

  1. 面向開發者的API,比如各種測試註解。
  2. 特定於某一測試框架的測試引擎。其中JUnit 5將呼叫Vintage Engine來相容JUnit 3和JUnit 4的測試,Juniper Engine則用來執行JUnit 5的測試。
  3. 通用測試引擎,是對第2層中各種框架引擎的抽象。
  4. 面向IDE的啟動器,IntelliJ IDEA、Eclipse等IDE通過啟動器來執行測試。

Test設定

JUnit可以在方法和類兩個級別完成初始化和後續操作,其中@BeforeEach和@AfterEach為方法級別的註解,@BeforeAll和@AfterAll為類級別的註解。TestNG同樣提供了@BeforeMethod和@AfterMethod作為方法級別的註解,@BeforeClass和@AfterClass作為類級別的註解。TestNG還多了@BeforeSuite、@AfterSuite、@BeforeGroup、@AfterGroup,提供套件以及組級別的設定能力。

停用測試

JUnit提供了@Ignore註解,而TestNG則是在@Test後加入了enable=false的引數:@Test(enable = false)。

套件/分組測試

所謂套件/分組測試,就是把多個測試組合成一個模組,然後統一執行。
在JUnit中利用了@RunWith、@SelectPackages、@SelectClasses註解來組合測試用例,比如:

@RunWith(JUnitPlatform.class)
@SelectClasses({Class1UnitTest.class, Class2UnitTest.class})
public class SelectClassesSuiteUnitTest {
}
複製程式碼

而在TestNG中,則用一個XML檔案來定義要組合的測試:

<suite name="suite">
    <test name="test suite">
        <classes>
            <class name="com.alibaba.icbu.product.Class1Test" />
            <class name="com.alibaba.icbu.product.Class2Test" />
        </classes>
    </test>
</suite>
複製程式碼

除此之外,TestNG還可以組合方法,在@Test註解中定義group:

@Test(groups = "regression")
public void regressionTestNegtiveSum() {
    int sum = numbers.stream().reduce(0, Integer::sum);
    Assert.assertTrue(sum < 0);
}
複製程式碼

然後再XML中定義如下:

<test name="test groups">
    <groups>
        <run>
            <include name="regression" />
        </run>
    </groups>
    <classes>
        <class
          name="com.alibaba.icbu.product.Class1Test" />
    </classes>
</test>
複製程式碼

異常測試

對於如下丟擲異常的方法:

public class Calculator {
    public double divide(double a, double b) {
        if (b == 0) {
            throw new DivideByZeroException("Divider cannot be equal to zero!");
        }
        return a/b;
    }
}
複製程式碼

在JUnit 5中,可以用assertThrows來斷言:

@Test
public void testDivideByZero() {
    Calculator calculator = new Calculator();
    assertThrows(DivideByZeroException.class, () -> calculator.divide(10, 0));
}
複製程式碼

在TestNG中,則可以在註解中加入期望的異常:

@Test(expectedExceptions = ArithmeticException.class) 
public void testDivideByZero() { 
    int i = 1 / 0;
}
複製程式碼

引數化測試

引數化的好處是重用測試方法來測試多組資料,我們可以申明資料來源,測試方法就能讀取各個資料進行測試。
在JUnit 5中,有如下幾種資料來源註解:

  • @ValueSource,可以定義Short、Byte、Int、Long、Float,、Double、Char和String陣列作為資料來源:java @ParameterizedTest @ValueSource(strings = { "Hello", "World" }) void testStringNotNull(String word) { assertNotNull(word); }
  • @EnumSource,把Enum作為引數:java @ParameterizedTest @EnumSource(value = ProductType.class, names = {"SOURCING", "MARKET"}) void testContainProductType(ProductType type) { assertTrue(EnumSet.of(ProductType.SOURCING, ProductType.MARKET).contains(type)); }
  • @MethodSource,呼叫函式產生引數:

    static Stream<String> wordDataProvider() {
      return Stream.of("foo", "bar");
    }
    
    @ParameterizedTest
    @MethodSource("wordDataProvider")
    void testInputStream(String argument) {
      assertNotNull(argument);
    }
    複製程式碼
  • @CsvSource,CSV值作為引數:

    @ParameterizedTest
    @CsvSource({ "1, Car", "2, House", "3, Train" })
    void testContent(int id, String word) {
      assertNotNull(id);
      assertNotNull(word);
    }
    複製程式碼
  • @CsvFileSource將會讀取classpath下的CSV檔案作為引數。
    而在TestNG中,主要有如下兩種引數化註解:

  • @Parameter,讀取XML檔案中的資料作為引數:

    <suite name="My test suite">
    <test name="numbersXML">
        <parameter name="value" value="1"/>
        <parameter name="isEven" value="false"/>
        <classes>
            <class name="com.alibaba.icbu.product.ParametrizedTests"/>
        </classes>
    </test>
    </suite>
    複製程式碼

    在Java程式碼中:

    @Test
    @Parameters({"value", "isEven"})
    public void testIsEven(int value, boolean isEven) {
      Assert.assertEquals(isEven, value % 2 == 0);
    }
    複製程式碼
  • @DataProvider,可以提供更復雜的類作為引數,通常定義一個返回Object[][]的函式作為資料提供者:

    @DataProvider(name = "numbers")
    public static Object[][] evenNumbers() {
      return new Object[][]{{1, false}, {2, true}, {4, true}};
    }
    
    @Test(dataProvider = "numbers")
    public void testIsEven(Integer number, boolean expected) {
      Assert.assertEquals(expected, number % 2 == 0);
    }
    複製程式碼

依賴測試

依賴測試是指測試的方法是有依賴的,在執行的測試之前需要執行的另一測試。如果依賴的測試出現錯誤,所有的子測試都被忽略,且不會被標記為失敗。JUnit目前不支援依賴,而在TestNG中,在@Test中加入dependsOnMethods = {"xxx"}即可。

並行測試

JUnit並行測試需要自己定製一個Runner,而在TestNG中,可以通過XML設定並行度:

<suite name="Concurrency Suite" parallel="methods" thread-count="2" >
  <test name="Concurrency Test" group-by-instances="true">
    <classes>
      <class name="com.alibaba.icbu.product.ConcurrencyTest" />
    </classes>
  </test>
</suite>
複製程式碼

綜上來看,JUnit 5在功能上已經和TestNG十分接近,但TestNG還是在引數化測試、依賴測試、並行測試上更加簡潔、強大。

3. Mock

Mock是單元測試中重要的一環,在許多場景中需要mock一些外部依賴,比如:

  • 依賴的外部服務的呼叫,比如一些webservice。
  • DAO層的呼叫,訪問MySQL、Tair等底層儲存。

根據之前所提到的單元測試的原則,我們可以專注於測試被測試主體的功能,而不是測試它的依賴。

基本概念

根據Martin Fowler的這篇文章,Mock有以下幾個基本概念:

  • Dummy:不包含實現的物件,在測試中需要被傳入,卻沒有真正的被使用,通常只是來填充引數列表。
  • Fake:有具體實現,但通常做了一些捷徑使之不能用於生產環境,比如記憶體資料庫。
  • Stubs:對於測試中的呼叫和請求,返回準備好的資料。
  • Spies:類似於Stubs,但會記錄被呼叫的成員,用於驗證資料。
  • Mocks:根據一系列物件將收到的呼叫已經預設好結果。

Mock原理

Mock主要分為三個階段:
1. Record階段:錄製期望。也可以理解為資料準備階段。建立依賴的Class或Interface或Method,模擬返回的資料、耗時及呼叫的次數等。
2. Replay階段:通過呼叫被測程式碼,執行測試。期間會Invoke到第一階段Record的Mock物件或方法。
3. Verify階段:驗證。可以驗證呼叫返回是否正確,及Mock的方法呼叫次數,順序等。

Mock框架

目前主流的Java Mock框架有JMockit、Mockito、EasyMock和PowerMock,功能對比如下:
Mock框架對比
從上圖可以看到,JMockit的功能最為全面和強大,就筆者的實際使用體驗來說,Mockito的API更加輕量易用。下面將以JMockit為例介紹一些基本的Mock。
(1) 測試設定
JMockit需要將Runner設定為JMockit。對於被Mock的物件,加上@Injectable(只建立一個Mock例項)和@Mocked(對於每個例項都建立一個Mock)註解即可。對於測試例項,加上@Tested註解。

@RunWith(JMockit.class)
public class JMockitExampleTest {

    @Tested
    JMockitExample jMockitExample;

    @Injectable
    TestDependency testDependency;
}
複製程式碼

在JMockit中,測試分為三個步驟:

  • Record:在一個new Expectations(){{}}區塊中定義Mock的行為及資料。
  • Replay:呼叫測試類中的某個測試方法,這將呼叫某個Mock物件。
  • Verification:在一個new Verifications(){{}}區塊中定義各種驗證。

    @Test
    public void testWireframe() { 
    new Expectations() {{ 
       // 定義mock期望的行為
    }};
    
    // 執行測試程式碼
    
    new Verifications() {{ 
       // 驗證mocks
    }};
    
    // 斷言
    }
    複製程式碼

    (2) Mock物件
    對於需要Mock的物件,將其加上@Mocked註解,作為測試方法的引數傳入即可。

    @Test
    public void testDoSomething(@Mocked TestDependency testDependency) throws Exception {
    }
    複製程式碼

    (3)Mock方法呼叫
    對於Mock方法呼叫,則是在Expectations區塊中定義mock.method(args); result = value;,如果想在多次呼叫時返回多個值,則可以使用returns(value1, value2,...)。包括異常的丟擲也可以在此定義。當返回的值需要一些計算邏輯時,我們就可以使用Delegate介面來定義result。
    對於傳入Mock方法的引數,JMockit提供了Any來適配通用引數。每個原始類別、String均有自己的AnyX定義,Any則用來匹配通用物件。
    Any更高階一些的是with方法,比如withNotNull()限制了傳入的引數不為null,withSubstring("xyz")限制了傳入的String需要含有"xyz"。

    @RunWith(JMockit.class)
    public class JMockitExampleTest {
    
    @Tested
    JMockitExample jMockitExample;
    
    @Test
    public void testDoSomething(@Mocked TestDependency testDependency) throws Exception {
        new Expectations() {{
            testDependency.intReturnMethod(); result = 3;
            testDependency.stringReturnMethod(); returns("str1", "str2"); result = SomeCheckedException();
            testDependency.methodForDelegate();
            result = new Delegate() {
                public int delegate(int i) throws Exception {
                    if (i < 3) {
                        return 5;
                    } else {
                        throw new Exception();
                    }
                }
            }
            testDependency.passStringMethod(anyString);
            testDependency.methodForTimes; times = 2;
        }}
    }
    
    jMockitExample.doSomething();
    }
    複製程式碼

    (4)Mock靜態方法
    在被測試程式碼中,常常需要呼叫一個外部類的一個靜態方法,這時候需要用到JMockit中的MockUp類。如果不想執行相關初始化邏輯,即可用$clinit()模擬掉。

    public class TestUtils {
        public static String staticMethod() {}
    }
    @Test
    public void testDoSomething() {
        new MockUp<TestUtils>() {
            @Mock
            void $clinit() {}
    
            @Mock
            public String staticMethod() {
                return "str";
            }
        };
    }
    複製程式碼

    (5)Verification
    在Verification區塊中,Expectations中提到的Any以及with都可以使用。如果要驗證方法呼叫的順序,則可以直接建立VerificationsInOrder。也可以使用FullVerifications確保所有呼叫都被驗證。

4. 斷言

JUnit 5、TestNG這些單測框架都有自己的斷言,提供了基礎的API,基本能滿足全部斷言需求。但其缺點是不對各類資料做邏輯封裝,比如判斷一個String是否以"abc"開頭,需要我們自己去實現。除了自帶的斷言,第三方斷言工具中比較流行的是AssertJ和HamCrest。HamCrest並不是一個只針對單元測試的庫,只是其中豐富的匹配器特別適合和斷言配合使用。而AssertJ同樣提供了豐富的API,不僅涵蓋了基礎型別、異常、日期、soft斷言,還對DB、Stream、Optional等提供了支援。其流式斷言的風格不僅使程式碼更加精簡優雅,還增強了程式碼的可讀性。對於AssertJ API的例子可以參考此處

5. 測試覆蓋率

單元測試中我們主要關注:

  • 語句覆蓋率
  • 分支覆蓋率

我們可以在pom中加入一些maven外掛來幫助我們產生測試覆蓋率報告。常用的測試覆蓋率報告外掛有:

  • JaCoCo
  • clover
  • cobertura

以cobertura舉例,執行mvn cobertura:cobertura後,report會產生在${project}/target/site/cobertura/index.html。

ATDD

ATDD全稱Acceptance Test Driven Development,驗收驅動測試開發。主要是由QA編寫測試用例。根據驗收方法和型別的不同,ATDD又包含了BDD(Behavior Driven Development)、EDD(Example Driven Development),FDD(Feature Driven Development)、CDCD(Consumer Driven Contract Development)等各種的實踐方法。

TDD及單元測試最佳實踐


相關文章