TDD及單元測試最佳實踐
TDD:What?Why?How?
TDD(測試驅動開發)既是一種軟體開發技術,也是一種設計方法論。其基本思想是通過測試來推動整個開發的進行,但測試驅動開發並不只是單純的測試工作,而是把需求分析、設計、質量控制量化的過程。
為什麼要採用TDD呢?TDD有如下幾點優勢:
- 在開發的過程中,把大的功能塊拆分成小的功能塊進行測試,降低複雜性,幫助我們小步快跑前進。
- 遵循“keep it simple, stupid”(KISS)和“You aren't gonna need it”(YAGNI)原則,只寫通過測試的必要程式碼,所以程式碼通常精簡清晰(clean and clear)。
- 由於寫測試用例實際上在模仿使用者,所以可以提升程式碼結構和介面設計的合理性。
- 儘早的暴露問題並解決,減小後續測試成本,長遠的看還可以最大限度規避線上故障。
- 測試程式碼即文件,測試程式碼中的用例、入參、預期結果是對程式碼最好的解釋。
- 當一個需求來的時候,我們首先要做的就是增加一個測試或者重寫當前的相關測試。這個過程中,我們需要非常清楚的瞭解需求本質,反映在測試用例上,就是測試的輸入是什麼,得到的輸出是什麼。而測試資料也需要儘量包括真實資料和邊界資料。
- 執行測試,預期中,這個測試會失敗,因為相關功能還沒有被我們加在程式碼中。
- 編寫相關功能的程式碼,從而讓測試通過。
- 重新執行測試,這時候不僅要看第一步中的測試有沒有通過,還需要看以前通過的測試有沒有fail。如果測試失敗,那麼需要重寫編寫程式碼或者更新相關測試。
- 重構程式碼,為了讓新增的測試通過,不免會堆積程式碼,所以要時候保持重構,去除程式碼中的“bad smell”。
下面將用我們重構中的一個簡單的案例來展示TDD的過程。我們需要一個工具類來實現一個方法根據商品的tag判斷一個商品是否是批發商品:
明確需求和測試用例
批發商品的tag為Long型的10000L,傳入的商品tags為一個String,以逗號分隔的各個商品tag,比如"10000, 12345"
。我們的測試用例為如下幾個:
入參 結果 "" false "12345" false "10000" true "12345,10000,20000" true "&^837,20000,10000" true 實現方法
我們的測試為:@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 Beck和Erich Gamma開發,後來由JUnit團隊開發維護,截止到本文寫作時間已釋出JUnit 5。TestNG作為後起之秀,在JUnit的功能之外提供了一些獨特的功能。下面將結合一些程式碼案例對兩個框架的基本功能進行對比,其中JUnit將集中關注JUnit5中的功能。
總體架構
- 面向開發者的API,比如各種測試註解。
- 特定於某一測試框架的測試引擎。其中JUnit 5將呼叫Vintage Engine來相容JUnit 3和JUnit 4的測試,Juniper Engine則用來執行JUnit 5的測試。
- 通用測試引擎,是對第2層中各種框架引擎的抽象。
- 面向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,功能對比如下:
從上圖可以看到,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)等各種的實踐方法。