前言&背景
說起單元測試,每個開發人員都很熟悉,但很多人卻不重視。發現很多IT公司裡對于單測都沒有規範,最多也隻規定了一個覆寫率。很多開發人員認為單測屬于可有可無,意義不大。或者有時間就寫,沒時間就算了的情況,甚至認為:“反正有測試同學幫忙把控代碼品質,為什麼還要開發浪費時間寫單測呢?難道不是重複工作麼?”。這個問題其實很有代表性,很多開發因為有這個想法,就算寫了單測,可能也隻是敷衍了事或者随意發揮,寫出來的單測五花八門,沒有規範可言,也就沒有任何實際價值,純粹是為了完成任務而已。

單元測試目的
正是因為目前業内對單測沒有統一的标準和要求,對于單測的意義和價值也有這樣那樣的疑惑,是以希望探讨一下對單元測試的一些想法,求同存異。
首先要解釋一下前言中開發同學的問題:
- 第一,職責的不同。開發人員的職責是什麼?是完成一個功能的開發并保證其品質,而測試人員的職責則驗收開發的成果,為産品的品質把關,保證項目傳遞的品質。很明顯,保證代碼品質是開發這一環節的工作職責之一,你自己寫的代碼的品質自己都不把控,又怎麼能寄希望于别人能幫你把控呢?換句話說,在測試階段發現了你代碼裡的很多缺陷,其實就說明了你的開發職責沒有履行到位,本職工作都沒有完成。
- 第二,成本的不同。對于同一個缺陷,在越早期發現,修複的成本就越小,這個道理想必大家都知道。如果很多的品質問題,都需要到測試階段才能發現,才來返工修複,這對整體項目的時間和資源來說絕對是不小的浪費。
- 第三,角度的不同。對于開發人員來說,寫單測更多的是針對單個方法的邏輯的測試,而對于測試人員來說,更多的是針對功能的黑盒測試,很多方法内部的邏輯其實并沒有辦法測到,這些邏輯是必須用單測才能覆寫到的。
然後談談個人對單測的看法。單測的作用到底是什麼?意義究竟如何展現?
- 第一,單測可以很好保證代碼品質,進而增加開發人員的信心,這點就不再贅述了。
- 第二,單測可以一定程度提高代碼合理性。當我們發現給一個方法寫單測非常困難,比如單測需要覆寫的分支非常多,那可能說明方法可以拆分;又比如單測需要mock的調用非常多,那可能說明方法違背了單一責任原則,處理了太多的邏輯,也可以拆分等等。
- 第三,單測能夠有效防止回溯問題(regression issue)的出現。所謂回溯問題,指的就在之前版本沒有在新版本才出現的問題。這種問題的嚴重程度是最高的,影響也是最惡劣的。原因很簡單:使用者可以接受一個本來在老版本就不存在的功能不可用,但是一定無法接受一個本來在老版本用地好好的功能突然失效了。在新功能開發完後,運作老功能單測,如果發現未變更邏輯的老功能單測報錯,則很有可能是出現了回溯問題。
- 第四,單測能夠幫助測試人員确定回歸範圍。這一點其實是第三點擴充。在新功能提測的時候,開發人員需要提供測試範圍,畢竟随着功能的不停增加,全量回歸已經變得越來越不可能了。有些開發同學,為了安全起見,随意增加回歸範圍,這無疑增加了測試人員不必要的工作,是一種嚴重浪費測試資源的行為。在新功能開發完後,運作老功能單測,如果發現單測報錯,則說明這部分老功能的邏輯可能發生了變化,單測需要進行相應的調整,且相關功能應該屬于回歸的範圍
單元測試規範
一. 可衡量:單測的編寫應該是可以用具體的名額衡量的
單測通過率要求100%,行覆寫率要求50%。
解釋:通過率100%沒啥好多說的,如果單測跑不通過,那不是單測有問題就是代碼邏輯有問題。覆寫率的話可以根據具體的工程進行微調,建議不應小于40%,越底層的代碼覆寫率應該越高,越新的代碼覆寫率也應該越高。
老代碼有邏輯變更時,單測也應該做相應的變更。
解釋:這點的目的也是為了保證單測通過率100%。同時,這部分功能應該也屬于改次功能的測試回歸範圍内。
新業務提測前,必須保證老單測的通過率也保持100%。
解釋:這點的目的是為了防止回溯問題的出現。
二. 獨立性:單測應該是獨立且互相隔離的
一個單測隻測試一個方法。
解釋:保證了單測的獨立性。當單測出錯的時候也能夠明确知道是哪個方法出了問題。但這并不是說一個方法隻對應一個單測,因為為了覆寫方法内的不同分支,我們可以為一個方法建立多個單測。
單測不應該依賴于别的單測。
解釋:保證了單測的獨立性。每個單測應該都能獨立運作。不應該有A單測跑完才能跑B單測的情況。
單測如果涉及到資料變更,必須進行復原。
解釋:保證了單測的隔離性。如果單測運作後在資料庫中産生了資料,那這些髒資料可能幹擾測試同學的測試工作,且也可能影響别的單測的運作結果。
單測應該測試目标方法本身的邏輯,對于被測試的方法内部調用的非私有方法應進行mock,推薦使用Mockito進行mock。
解釋:目标方法存在内部調用情況,進行mock可以屏蔽其他方法對目标方法的影響。這樣保證了單測的獨立性,一個單測隻保證它測試的目标方法的邏輯正确性,而不應該受其内部調用方法的邏輯的影響,這部分應該是這些内部調用的方法對應的單測的責任。但是真實情況中,這一點是最難被嚴格執行,因為這樣做就意味着需要對所有的方法都設計單測,比如a調用b調用c的情況,需要至少設計三個單測,而不能隻對a設計單測來覆寫整個調用鍊。不過,這不正是單測的含義嗎?對最小的邏輯單元——方法進行測試,如果對于一個調用鍊進行測試,更像是內建測試的範疇了。而且如果不這麼做,我們就會違反上面的第4條“一個單測隻測試一個方法”。隻有一種情況例外,方法内部調用的是私有方法,這樣的話是可以通過調用方的單測一并測試的,見下面的第13條“私有方法通過調用類的單測進行測試”。我們可以試想一種情況,當一個項目由很多人協同開發時,我怎麼才能放心使用另一個人開發的方法?至少得提供單測吧,如果這個方法的測試是在其調用方的單測中的,那就沒有直接對應的單測了,這樣也就無法保證該方法是否被妥當測試過了。
三. 規範性:單測的編寫需要符合一定規範
對實作類進行測試而非接口。
解釋:面向接口程式設計,面向實作測試。
單測應該是無狀态的。
解釋:即單測應該可以重複執行,且無論跑幾次都應該保證通過率。比如有些方法會對目前時間進行判斷,對于這類方法的單測也需根據目前時間的不同而進行不同的測試。
覆寫範圍應包括所有提供了邏輯的類:service層、manager層、自定義mapper等,甚至還有部分提供業務邏輯的controller層代碼。
解釋:隻要是提供了邏輯的就應該測試,不過個人并不建議在controller層提供業務邏輯,具體原因參考《設計之道-controller層的設計》。
覆寫範圍不應包括自動生成的類:如MyBatis Generator生成的Mapper類、Example類,不應包括各種POJO(DO,BO,DTO,VO...),也不應包括無業務邏輯的controller類。
解釋:自動生成的類有啥好測的?POJO的getter/setter有啥好測的?沒有提供業務邏輯的controller類有啥好測的?這些被排除的類應該在覆寫率統計中被剔除。
私有方法通過調用類的單測進行測試。
解釋:因為私有方法在測試類内沒法直接調用,除非使用反射或其他Mock架構(PowerMock, TestableMock等)。
單測要覆寫到正常分支和異常分支,使用專門的異常測試屬性junit(expected)/testng(expectedExceptions)。禁止使用try-catch。
解釋:很多同學的單測覆寫率不達标,就是因為隻覆寫了正常的分支而遺漏的異常的分支。異常的測試和正常的一樣重要,也就是該報錯的時候就應該報錯。有些同學為了達到單測的覆寫率和通過率的名額,在單測中使用try-catch,這也是不允許的,應該使用專門的異常測試注解。
如果被測試的方法的邏輯展現在方法傳回或成員變量中,則使用Assert斷言驗證該傳回或成員變量。
解釋:如果一個方法的内部組裝了一個傳回值,或變更了一個成員變量,那麼應該使用Assert來驗證該傳回值或成員變量是否符合預期。
比如下面的三個方法,前兩個的邏輯都是展現在傳回值上,後一個的邏輯展現在成員變量中。
/**
* 邏輯展現在傳回值
*
* @return
*/
public String displayName() {
String name = "HangzhouZoo";
return "Zhejiang " + name;
}
/**
* 邏輯展現在傳回值
*
* @return
*/
public String luxuryShow() {
String show = dog.run();
return "luxury!! " + show;
}
/**
* 邏輯展現在成員變量
*/
public void close() {
this.open = false;
}
那麼我們就可以使用Assert斷言來測試這些邏輯:
//邏輯在方法傳回展現
@Test
public void displayName() {
Assert.assertEquals("Zhejiang HangzhouZoo", hangzhouZoo.displayName());
}
//邏輯在方法傳回展現
@Test
public void luxuryShow() {
when(dog.run()).thenReturn("dog show");
Assert.assertEquals("luxury!! dog show", hangzhouZoo.luxuryShow());
}
//邏輯在成員變量中展現
@Test
public void close() {
Assert.assertTrue(hangzhouZoo.isOpen());
hangzhouZoo.close();
Assert.assertFalse(hangzhouZoo.isOpen());
}
如果被測試的方法的邏輯展現在内部的方法調用行為本身,則使用Mockito的verify驗證内部方法調用的情況。
解釋:有些方法的内部根據不同的條件會調用不同的方法,則應該驗證該方法的調用是否符合預期。Mockito的verify可以驗證被mock的方法是否調用了,甚至可以驗證方法調用的次數。
比如下面這個方法有三分條件分支,分支一抛出異常,分支二調用内部方法,分支三組裝傳回值。
/**
* 邏輯展現在異常、方法調用行為和傳回值
*
*/
@Override
public String show(Animal animal) throws ZooException {
if (animal instanceof Tiger) {
throw new ZooException("tiger is not allowed");
} else if (animal instanceof Dog) {
return animal.run();
} else {
return "only dogs here";
}
}
其中分支二的邏輯就展現在方法調用的行為上,我們可以通過verify來驗證方法是否如預期一樣調用,也可使用times驗證方法調用的次數。
//被測試的方法的邏輯展現在内部方法的調用行為本身
@Test
public void show() throws Exception {
when(dog.run()).thenReturn("dog run");
hangzhouZoo.show(dog);
//驗證方法被調用過了
verify(dog).run();
//也可以通過times參數來驗證方法具體被調用的次數
verify(dog, times(1)).run();
//驗證另一個分支,邏輯展現在傳回值
Assert.assertEquals("only dogs here", hangzhouZoo.show(new Cat()));
}
當然,還記得第13條“異常分支也需要測試麼”,我們還需要寫一個單測來覆寫異常分支:
//測試異常分支
@Test(expected = ZooException.class)
public void showForEx() throws Exception {
hangzhouZoo.show(new Tiger());
}
如果被測試的方法的邏輯展現在内部方法調用的參數中,即方法的邏輯用于建構内部調用方法的參數,則使用Mockito的verify驗證内部方法調用的參數。
解釋:有些方法的内部會組裝一個對象,然後将這個對象作為參數傳入另一個内部方法。使用Mockito的verify可以驗證被mock的方法被調用的參數。如果是簡單類型,可以直接驗證,如果是複雜類,則需要擴充
ArgumentMatcher
類來做驗證。
下面這個方法的邏輯展現在内部調用方法的參數構造上:
/**
* 邏輯展現在參數構造-基本類
*
* @param times
*/
public void bark(int times) {
int actualTimes = times * 10;
dog.bark(actualTimes);
}
由于參數類型是基本類,是以我們可以直接用verify來驗證:
//邏輯在參數展現-簡單類型
@Test
public void bark() {
doNothing().when(dog).bark(anyInt());
hangzhouZoo.bark(3);
verify(dog).bark(30);
//與上面等價
verify(dog).bark(eq(30));
}
不過如果像下面這樣的參數是複雜類的,就需要擴充一下:
/**
* 邏輯展現在參數構造-複雜類
*
* @param
* @return
*/
public String feedVegetable() {
Food tomato = Food.builder().name("tomato").build();
return dog.eat(tomato);
}
自定義參數比對器:
/**
* 自定義參數比對規則
*/
public class ObjectMatcher<T> extends ArgumentMatcher<T> {
private Object expected;
private Function<T, Object> getProperty;
public ObjectMatcher(Object expected, Function<T, Object> getProperty) {
this.expected = expected;
this.getProperty = getProperty;
}
@SuppressWarnings("unchecked")
@Override
public boolean matches(Object actual) {
return getProperty.apply((T) actual).equals(expected);
}
}
測試的時候使用argThat校驗方法參數:
//邏輯在參數展現-複雜類
@Test
public void feedVegetable() {
when(dog.eat(any())).thenReturn("dog eat");
hangzhouZoo.feedVegetable();
//驗證參數
verify(dog).eat(argThat(new ObjectMatcher<>("tomato", Food::getName)));
}
單測應在相應的目标方法開發完後立即編寫,如能在開發前就開始編寫則更好(TDD)。
解釋:這點可能會違背很多開發同學的認知,怎麼可能先寫單測再寫代碼呢?實際上,如果稍微了解下測試驅動開發(Test-Driven Development),就會發現這并非異想天開,反倒是順理成章的事。我認為有兩種場景下單測的習慣是很容易能夠推動的,第一種是團隊裡沒有測試人員,代碼品質完全由開發人員把控;而第二種就是軟體開發流程使用的是TDD的方式,這樣天然的就保證了單測必須存在。
原文: 設計之道-單元測試規範 作者:SawyerZhou