單元測試
故事場景
工廠生產(chǎn)電視機
工廠首先會將各種電子元器件按照圖紙組裝在一起構(gòu)成各個功能電路板,比如供電板、音視頻解碼板、射頻接收板等,然后再將這些電路板組裝起來構(gòu)成一個完整的電視機。
如果一切順利,接通電源后,你就可以開始觀看電視節(jié)目了。但是很不幸,大多數(shù)情況下組裝完成的電視機根本無法開機,這時你就需要把電視機拆開,然后逐個模塊排查問題。
假設(shè)你發(fā)現(xiàn)是供電板的供電電壓不足,那你就要繼續(xù)逐級排查組成供電板的各個電子元器件,最終你可能發(fā)現(xiàn)罪魁禍首是一個電容的故障。這時,為了定位到這個問題,可能已經(jīng)花費了大量的時間和精力。
如何避免?
如何才能避免類似的問題呢?
為什么不在組裝前,就先測試每個要用到的電子元器件呢?這樣就可以先排除有問題的元器件,最大程度地防止組裝完成后逐級排查問題的事情發(fā)生。
單元測試 VS 工廠生產(chǎn)電視機
如果把電視機的生產(chǎn)、測試和軟件的開發(fā)、測試進行類比,可以發(fā)現(xiàn):
- 電子元器件就像是軟件中的單元,通常是函數(shù)或者類,對單個元器件的測試就像是軟件測試中的單元測試;
- 組裝完成的功能電路板就像是軟件中的模塊,對電路板的測試就像是軟件中的集成測試;
- 電視機全部組裝完成就像是軟件完成了預(yù)發(fā)布版本,電視機全部組裝完成后的開機測試就像是軟件中的系統(tǒng)測試。
通過類比,可以發(fā)現(xiàn)單元測試的重要性。那么單元測試到底是什么呢?
單元測試-基本概念
單元測試是指,對軟件中的最小可測試單元在與程序其他部分相隔離的情況下進行檢查和驗證的工作,最小可測試單元通常是指函數(shù)或者類。
維基百科中這樣定義:
單元測試(Unit Testing)又稱為模塊測試,是針對程序模塊來進行正確性檢驗的測試工作。在過程化編程中,一個單元就是單個程序、函數(shù)、過程等;對于面向?qū)ο缶幊?,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法?/p>
其實,對“單元”的定義取決于自己。如果正在使用函數(shù)式編程,一個單元最有可能指的是一個函數(shù),單元測試將使用不同的參數(shù)調(diào)用這個函數(shù),并斷言它返回了期待的結(jié)果;在面向?qū)ο笳Z言里,下至一個方法,上至一個類都可以是一個單元。
單元測試-金字塔模型
冰淇淋模型
在金字塔模型之前,流行的是冰淇淋模型。包含了大量的手工測試、端到端的自動化測試及少量的單元測試。造成的后果是,隨著產(chǎn)品壯大,手工回歸測試時間越來越長,質(zhì)量很難把控;自動化 case 經(jīng)常失敗,每一個失敗對應(yīng)著一個很長的函數(shù)調(diào)用。

哪里出了問題?
- 單元測試太少,基本沒起作用。
金字塔模型
”測試金字塔“ 比喻非常形象,讓人一眼就知道測試是需要分層的,并且還告訴你每一層需要寫多少測試。測試金字塔具備兩點經(jīng)驗法則:
- 編寫不同粒度的測試
- 層次越高,寫的測試應(yīng)該越少

可以把金字塔模型理解為——冰激凌融化了。就是指,最頂部的“手工測試”理論上全部要自動化,向下融化,優(yōu)先全部考慮融化成單元測試,單元測試覆蓋不了的放在中間層(分層測試),再覆蓋不了的才會放到 UI 層。 因此,不分單元測試還是分層測試,統(tǒng)一都叫自動化測試,把所有的自動化 case 看做一個整體,case不要冗余,單元測試能覆蓋,就要把這個case從分層或ui中去掉。越是底層的測試,牽扯到相關(guān)內(nèi)容越少,而高層測試則涉及面更廣。
單元測試:它的關(guān)注點只有一個單元,而沒有其它任何東西。所以,只要一個單元寫好了,測試就是可以通過的
集成測試:要把好幾個單元組裝到一起才能測試,測試通過的前提條件是,所有這些單元都寫好了,這個周期就明顯比單元測試要長
系統(tǒng)測試:要把整個系統(tǒng)的各個模塊都連在一起,各種數(shù)據(jù)都準備好,才可能通過。另外,因為涉及到的模塊過多,任何一個模塊做了調(diào)整,都有可能破壞高層測試
單元測試-意義
- 在開發(fā)早期以最小的成本保證局部代碼的質(zhì)量
- 在單元測試代碼里提供函數(shù)的使用示例
- 實施過程中幫助開發(fā)工程師改善代碼的設(shè)計與實現(xiàn)
- 單元測試都是以自動化的方式執(zhí)行,在大量回歸測試的場景下更能帶來高收益
如何做好單元測試
- 明確單元測試的對象是代碼,代碼的基本特征和產(chǎn)生錯誤的原因
- 對單元測試的用例設(shè)計有深入的理解
- 掌握單元測試的基本方法和主要技術(shù)手段 - 驅(qū)動代碼、樁代碼和 Mock 代碼等
代碼的基本特征與產(chǎn)生錯誤的原因
拋開業(yè)務(wù)邏輯,從代碼結(jié)構(gòu)來看:
所有的代碼無異于條件分支、循環(huán)處理和函數(shù)調(diào)用等最基本的邏輯控制,都是在對數(shù)據(jù)進行分類處理,每一次條件判定都是一次分類處理。
- 如果有任何一個分類遺漏,都會產(chǎn)生缺陷
- 如果有任何一個分類錯誤,也會產(chǎn)生缺陷
- 如果分類正確也沒有遺漏,但是分類時的處理邏輯錯誤,也同樣會產(chǎn)生缺陷
對單元測試的用例設(shè)計有深入的理解
單元測試的用例是一個“輸入數(shù)據(jù)”和“預(yù)計輸出”的集合。就是在明確了代碼需要實現(xiàn)的邏輯功能的基礎(chǔ)上,什么輸入,應(yīng)該產(chǎn)生什么輸出。但是會存在下面的誤區(qū):
誤區(qū):
- 輸入:只有被測試函數(shù)的輸入?yún)?shù)是“輸入數(shù)據(jù)”
- 輸出:只有函數(shù)返回值是”輸出數(shù)據(jù)“
完整的單元測試“輸入數(shù)據(jù)”
- 被測試函數(shù)的輸入?yún)?shù)~~~~
- 被測試函數(shù)內(nèi)部需要讀取的全局靜態(tài)變量
- 被測試函數(shù)內(nèi)部需要讀取的成員變量
- 函數(shù)內(nèi)部調(diào)用子函數(shù)獲得的數(shù)據(jù)
- 函數(shù)內(nèi)部調(diào)用子函數(shù)改寫的數(shù)據(jù)
- 嵌入式系統(tǒng)中,在中斷調(diào)用時改寫的數(shù)據(jù)
- ...
完整的單元測試“輸出數(shù)據(jù)”
- 被測試函數(shù)的返回值
- 被測試函數(shù)的輸出參數(shù)
- 被測試函數(shù)所改寫的成員變量
- 被測試函數(shù)所改寫的全局變量
- 被測試函數(shù)中進行的文件更新
- 被測試函數(shù)中進行的數(shù)據(jù)庫更新
- 被測試函數(shù)中進行的消息隊列更新
- ...
掌握單元測試的基本方法和主要技術(shù)手段 - 驅(qū)動代碼、樁代碼和 Mock 代碼等
- 驅(qū)動代碼(Driver): 指調(diào)用被測函數(shù)的代碼,通常包括了被測函數(shù)前的數(shù)據(jù)準備(如 @Before 修飾的代碼)、調(diào)用被測函數(shù)以及驗證相關(guān)結(jié)果,結(jié)構(gòu)通常由單元測試框架決定
- 樁代碼(Stub): 是用來代替真實代碼的臨時代碼
- Mock 代碼: 和樁代碼非常類似,都是用來代替真實代碼的臨時代碼,起到隔離和補齊的作用,和樁代碼的本質(zhì)區(qū)別是:測試期待結(jié)果的驗證(Assert and Expectiation)。

樁代碼-被測函數(shù)
示例:函數(shù)A是被測函數(shù),內(nèi)部調(diào)用了函數(shù)B
void funcA(){
boolean funcB_retVal = funcB();
if (true == funcB_retV){
do Operation 1;
}else{
do Operation 2;
}
}
樁代碼-樁函數(shù)
在單元測試階段,由于函數(shù)B尚未實現(xiàn),但是為了不影響對函數(shù)A的測試,可以用一個假的函數(shù)B來代替真實的函數(shù)B,那么這個假的函數(shù)B就是樁函數(shù)。
并且,為實現(xiàn)函數(shù)A的全路徑覆蓋,需要控制不同的測試用例中函數(shù)B的返回值,代碼如下:
boolean funcB(){
if(testCaseID == 'TC0001'){
return true;
}else if(testCaseID == 'TC0002'){
return false;
}
}
當執(zhí)行第一個測試用例的時候,樁函數(shù)B應(yīng)該返回true,而當執(zhí)行第二個測試用例的時候,樁函數(shù)B應(yīng)該返回false。
樁代碼原則
- 具有與原函數(shù)完全相同的原形,僅僅是內(nèi)部實現(xiàn)不同
- 用于隔離和補齊的樁函數(shù),只需保持原函數(shù)聲明,加一個空實現(xiàn),目的是通過編譯鏈接
- 控制功能的樁函數(shù)要根據(jù)測試用例的需要,輸出合適的數(shù)據(jù)作為被測函數(shù)的輸入
同時,樁代碼關(guān)注點是利用 Stub 來控制被測函數(shù)的執(zhí)行路徑,不會去關(guān)注 Stub 是否被調(diào)用以及怎樣被調(diào)用。
Mock 代碼
關(guān)注點:
- Mock 方法有沒有被調(diào)用
- 以什么樣的參數(shù)被調(diào)用
- 被調(diào)用的次數(shù)
- 多個 Mock 函數(shù)的先后調(diào)用順序
- ...
所以,在使用Mock代碼的測試中,對于結(jié)果的驗證(也就是assert),通常出現(xiàn)在 Mock 函數(shù)中
Mock 測試
背景
對于持續(xù)交付中的測試來說,自動化回歸測試不可或缺,但存在如下三個難點:
- 測試數(shù)據(jù)的準備和清理
- 分布式系統(tǒng)的依賴
- 測試用例高度仿真
解決方案:
- Mock
- “回放”技術(shù)(記錄實際用戶在生產(chǎn)環(huán)境的操作,然后在測試環(huán)境中回放)
- 攔截:
- SLB 統(tǒng)一做攔截和復(fù)制轉(zhuǎn)發(fā)處理;主路徑影響路由,容易故障
- 集群擴容一臺軟交換服務(wù)器,負責復(fù)制和轉(zhuǎn)發(fā)用戶請求;
- 回放
- 攔截:
Mock 背景 - 分布式系統(tǒng)依賴
微服務(wù)項目中會出現(xiàn)相互依賴的關(guān)系,比如由于服務(wù) B 依賴服務(wù) C,而服務(wù) C 還沒有開發(fā)完成,導致即使服務(wù) A 和服務(wù) B 都沒問題,但也沒有辦法完成服務(wù) A 的接口測試。

還有的會依賴數(shù)據(jù)庫、消息中間件等:

Mock 基本概念介紹
Mock 測試就是在測試過程中,對于某些不容易構(gòu)造或者不容易獲取的對象,用一個虛擬的對象來創(chuàng)建以便測試的測試方法。
好處
團隊并行工作
團隊間不需互相等待對方進度,只需約定好相互之間的數(shù)據(jù)規(guī)范(接口文檔),即可使用 mock 構(gòu)建出可用接口,然后盡快進行開發(fā)和自測,提前發(fā)現(xiàn)缺陷測試驅(qū)動開發(fā) TDD (Test-Driven Development)
單元測試是 TDD 實現(xiàn)的基石,而 TDD 經(jīng)常會碰到協(xié)同模塊尚未開發(fā)完成的情況,但有了 mock,當接口定義好后,測試人員就可以創(chuàng)建一個 Mock,把接口添加到自動化測試環(huán)境,提前創(chuàng)建測試。測試覆蓋率
若一個接口在不同的狀態(tài)下要返回不同的值,常見做法是復(fù)現(xiàn)這種狀態(tài)然后再去請求接口,而這種方法很可能因操作時機或方式不當導致失敗,甚至污染后端存儲如數(shù)據(jù)庫等, 但用 mock 則不用擔心隔離系統(tǒng)
使用某些接口時,為避免系統(tǒng)數(shù)據(jù)庫被污染,可以將接口調(diào)整為 Mock 模式,以保證數(shù)據(jù)庫純凈。方便演示
Mock 框架介紹
Mock 技術(shù)主要的應(yīng)用場景可以分為兩類:
-
基于對象和類的 Mock
- Mockito & PowerMock
-
基于微服務(wù)的 Mock
- Moco、MockMVC、WireMock、Mock Server
因為項目主要基于 Java 開發(fā), 因此下面主要介紹 Java 相關(guān)的 Mock 框架, 其他語言思想類似
基于對象和類的 Mock
- 原理:
- 在運行時,為每一個被 Mock 的對象或類動態(tài)生成一個代理對象,由這個代理對象返回預(yù)先設(shè)計的結(jié)果
- 場景:
- 適合模擬 DAO 層的數(shù)據(jù)操作和復(fù)雜邏輯,常用于用于單元測試階段
基于微服務(wù)的 Mock
從代碼編寫的角度來看,實現(xiàn)方式如下:
- 聲明被代理的服務(wù)
- 通過 Mock 框架定制代理的行為
- 調(diào)用代理,從而獲得預(yù)期的結(jié)果
Mockito & PowerMock ★★
Mockito 是 GitHub 上使用非常廣泛的 Java Mock 框架, star 數(shù) 11k, 在包括 openstack4j 和 kubernetes-client/java 等都有用到。Mockito 與 JUnit 結(jié)合使用, 能隔離外部依賴以便對自己的業(yè)務(wù)邏輯代碼進行單元測試在編寫單元測試需要調(diào)用某一個接口時,可以模擬一個假方法,并任意指定方法的返回值。
但缺點是 Mockito 2 版本對靜態(tài)方法、final 方法、private 方法和構(gòu)造函數(shù)的功能支持并不完善, 因此 PowerMock 則在 Mockito 原有的基礎(chǔ)上做了擴展,通過修改類字節(jié)碼并使用自定義 ClassLoader 加載運行的方式來實現(xiàn) mock 靜態(tài)方法、final 方法、private 方法和構(gòu)造函數(shù)等功能。
Mockito & PowerMock 一般測試步驟
1. mock: 模擬對象
用 mock()/@Mock 或 spy()/@Spy 創(chuàng)建模擬對象, 兩者創(chuàng)建出來的模擬對象區(qū)別是: 使用 mock 生成的對象,所有方法都是被 mock 的,除非某個方法被 stub 了,否則返回值都是默認值; 使用 spy 生產(chǎn)的 spy 對象,所有方法都是調(diào)用的 spy 對象的真實方法,直到某個方法被 stub 后
2. stub: 定義樁函數(shù)
可以通過 when()/given()/thenReturn()/doReturn()/thenAnswer() 等來定義 mock 對象如何執(zhí)行, 如果提供的接口不符合需求, 還可以通過實現(xiàn) Answer 接口來自定義實現(xiàn)
3. run: 執(zhí)行調(diào)用
執(zhí)行實際方法的調(diào)用,此時被 mock 的對象將返回自定義的樁函數(shù)的返回值
4. verify: 可選, 對調(diào)用進行驗證, 如是否被調(diào)用, 調(diào)用次數(shù)等
這一步可以對 mock 對象的方法是否被調(diào)用以及被調(diào)用次數(shù)進行驗證,同時還可以對參數(shù)捕獲進行參數(shù)校驗
下面以操作 Redis 和 RabbitMQ 來進行簡單舉例。
首先引入依賴:
<powermock.version>2.0.2</powermock.version>
...
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<version>${powermock.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito2</artifactId>
<version>${powermock.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
Redis 示例
// redis 操作類
class RedisDemo {
private Jedis jedis;
public void setUp() {
jedis = new Jedis("127.0.0.1", 6379);
jedis.connect();
}
public boolean isAdmin(String user) {
String ret = jedis.get("name");
if (user.equals(ret)) {
return true;
}
return false;
}
public void set(String key, String val) {
jedis.set(key, val);
}
public String get(String key) {
String s = jedis.get(key);
return s;
}
void out(){
System.out.println("ss");
}
}
// 單元測試類
@RunWith(PowerMockRunner.class) //讓測試運行于PowerMock環(huán)境
public class RedisMockitoTest {
@Mock //此注解會自動創(chuàng)建1個mock對象并注入到@InjectMocks對象中
private Jedis jedis;
@InjectMocks
private RedisDemo demo;
@Mock
StringOperator stringOperator;
//第1種方式
@Test
public void redisTest1() throws Exception {
Mockito.when(jedis.get("name")).thenReturn("admin");
boolean admin = demo.isAdmin("admin");
assertTrue(admin);
}
//第2種方式
@Test
public void redisTest2() {
RedisDemo demo = mock(RedisDemo.class);
ReflectionTestUtils.setField(demo, "jedis", jedis);
when(demo.isAdmin("admin")).thenReturn(true);
boolean admin = demo.isAdmin("admin");
assertTrue(admin);
}
//第3種方式
@Test
public void redisTest3() {
RedisDemo demo = mock(RedisDemo.class);
doReturn(true).when(demo).isAdmin("admin");
System.out.println(demo.isAdmin("admin"));
}
}
RabbitMQ 示例
@Component
public class DirectReceiver {
@Autowired
RabbitTemplate rabbitTemplate;
public Object getMsg() {
return rabbitTemplate.receiveAndConvert("queue_demo");
}
}
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = Main.class)
public class RecvMessage {
@Spy
RabbitTemplate rabbitTemplate;
@InjectMocks
@Autowired
DirectReceiver receiver;
@Test
public void recvTest() {
doReturn("Mock answer").when(rabbitTemplate).receiveAndConvert("queue_demo");
System.out.println(rabbitTemplate.receiveAndConvert("queue_demo"));
}
}
更多示例
public class Node {
private int num;
private String name;
public static Node getStaticNode() {
return new Node(1, "static node");
}
public Node() {
}
public Node(String name) {
this.name = name;
}
public Node(int num) {
this.num = num;
}
public Node(int num, String name) {
this.num = num;
this.name = name;
}
}
public class LocalServiceImpl implements ILocalService {
@Autowired
private IRemoteService remoteService;
@Override
public Node getLocalNode(int num, String name) {
return new Node(num, name);
}
@Override
public Node getRemoteNode(int num) {
return remoteService.getRemoteNode(num);
}
@Override
public Node getRemoteNode(String name) throws MockException {
try {
return remoteService.getRemoteNode(name);
} catch (IllegalArgumentException e) {
throw e;
}
}
@Override
public void remoteDoSomething() {
remoteService.doSometing();
}
}
public class RemoteServiceImpl implements IRemoteService {
@Override
public Node getRemoteNode(int num) {
return new Node(num, "Node from remote service");
}
@Override
public final Node getFinalNode() {
return new Node(1, "final node");
}
@Override
public Node getRemoteNode(String name) throws MockException {
if (StringUtils.isEmpty(name)) {
throw new MockException("name不能為空", name);
}
return new Node(name);
}
@Override
public void doSometing() {
System.out.println("remote service do something!");
}
@Override
public Node getPrivateNode() {
return privateMethod();
}
private Node privateMethod() {
return new Node(1, "private node");
}
@Override
public Node getSystemPropertyNode() {
return new Node(System.getProperty("abc"));
}
}
// 單元測試類
@RunWith(MockitoJUnitRunner.class) //讓測試運行于Mockito環(huán)境
public class LocalServiceImplMockTest {
@InjectMocks //此注解表示這個對象需要被注入mock對象
private LocalServiceImpl localService;
@Mock //此注解會自動創(chuàng)建1個mock對象并注入到@InjectMocks對象中
private RemoteServiceImpl remoteService;
@Captor
private ArgumentCaptor<String> localCaptor;
//如果不使用上述注解,可以使用@Before方法來手動進行mock對象的創(chuàng)建和注入,但會多幾行代碼
/*@Before
public void setUp() throws Exception {
localService = new LocalServiceImpl();
remoteService = mock(RemoteServiceImpl.class);
Whitebox.setInternalState(localService, "remoteService", remoteService);
}*/
/**
* any系列方法指定多參數(shù)情況
*/
@Test
public void testAny() {
Node target = new Node(1, "target");
when(remoteService.getRemoteNode(anyInt())).thenReturn(target); //靜態(tài)導入Mockito.when和ArgumentMatchers.anyInt后可以簡化代碼提升可讀性
Node result = localService.getRemoteNode(20); //上面指定了調(diào)用remoteService.getRemoteNode(int)時,不管傳入什么參數(shù)都會返回target對象
assertEquals(target, result); //可以斷言我們得到的返回值其實就是target對象
assertEquals(1, result.getNum()); //具體屬性和我們指定的返回值相同
assertEquals("target", result.getName()); //具體屬性和我們指定的返回值相同
}
/**
* 指定mock多次調(diào)用返回值
*/
@Test
public void testMultipleReturn() {
Node target1 = new Node(1, "target");
Node target2 = new Node(1, "target");
Node target3 = new Node(1, "target");
when(remoteService.getRemoteNode(anyInt())).thenReturn(target1).thenReturn(target2).thenReturn(target3);
//第一次調(diào)用返回target1、第二次返回target2、第三次返回target3
Node result1 = localService.getRemoteNode(1); //第1次調(diào)用
assertEquals(target1, result1);
Node result2 = localService.getRemoteNode(2); //第2次調(diào)用
assertEquals(target2, result2);
Node result3 = localService.getRemoteNode(3); //第3次調(diào)用
assertEquals(target3, result3);
}
/**
* 指定mock對象已聲明異常拋出的方法拋出受檢查異常
*/
@Test
public void testCheckedException() {
try {
Node target = new Node(1, "target");
when(remoteService.getRemoteNode("name")).thenReturn(target).thenThrow(new MockException("message", "exception")); //第一次調(diào)用正常返回,第二次則拋出一個Exception
Node result1 = localService.getRemoteNode("name");
assertEquals(target, result1); //第一次調(diào)用正常返回
Node result2 = localService.getRemoteNode("name"); //第二次調(diào)用不會正常返回,會拋出異常
assertEquals(target, result2);
} catch (MockException e) {
assertEquals("exception", e.getName()); //驗證是否返回指定異常內(nèi)容
assertEquals("message", e.getMessage()); //驗證是否返回指定異常內(nèi)容
}
}
/**
* 校驗mock對象和方法的調(diào)用情況
*/
public void testVerify() {
Node target = new Node(1, "target");
when(remoteService.getRemoteNode(anyInt())).thenReturn(target);
verify(remoteService, Mockito.never()).getRemoteNode(1); //mock方法未調(diào)用過
localService.getRemoteNode(1);
verify(remoteService, times(1)).getRemoteNode(anyInt()); //目前mock方法調(diào)用過1次
localService.getRemoteNode(2);
verify(remoteService, times(2)).getRemoteNode(anyInt()); //目前mock方法調(diào)用過2次
verify(remoteService, times(1)).getRemoteNode(2); //目前mock方法參數(shù)為2只調(diào)用過1次
}
/**
* mock對象調(diào)用真實方法
*/
@Test
public void testCallRealMethod() {
when(remoteService.getRemoteNode(anyInt())).thenCallRealMethod(); //設(shè)置調(diào)用真實方法
Node result = localService.getRemoteNode(1);
assertEquals(1, result.getNum());
assertEquals("Node from remote service", result.getName());
}
/**
* 利用ArgumentCaptor捕獲方法參數(shù)進行mock方法參數(shù)校驗
*/
@Test
public void testCaptor() throws Exception {
Node target = new Node(1, "target");
when(remoteService.getRemoteNode(anyString())).thenReturn(target);
localService.getRemoteNode("name1");
localService.getRemoteNode("name2");
verify(remoteService, atLeastOnce()).getRemoteNode(localCaptor.capture()); //設(shè)置captor
assertEquals("name2", localCaptor.getValue()); //獲取最后一次調(diào)用的參數(shù)
List<String> list = localCaptor.getAllValues(); //按順序獲取所有傳入的參數(shù)
assertEquals("name1", list.get(0));
assertEquals("name2", list.get(1));
}
/**
* 校驗mock對象0調(diào)用和未被驗證的調(diào)用
*/
@Test(expected = NoInteractionsWanted.class)
public void testInteraction() {
verifyZeroInteractions(remoteService); //目前還未被調(diào)用過,執(zhí)行不報錯
Node target = new Node(1, "target");
when(remoteService.getRemoteNode(anyInt())).thenReturn(target);
localService.getRemoteNode(1);
localService.getRemoteNode(2);
verify(remoteService, times(2)).getRemoteNode(anyInt());
// 參數(shù)1和2的兩次調(diào)用都會被上面的anyInt()校驗到,所以沒有未被校驗的調(diào)用了
verifyNoMoreInteractions(remoteService);
reset(remoteService);
localService.getRemoteNode(1);
localService.getRemoteNode(2);
verify(remoteService, times(1)).getRemoteNode(1);
// 參數(shù)2的調(diào)用不會被上面的校驗到,所以執(zhí)行會拋異常
verifyNoMoreInteractions(remoteService);
}
}
Moco
Moco 框架在開發(fā) Mock 服務(wù)的時候提供了一種不需任何編程語言的方式, 可以通過撰寫它約束的 json 建立服務(wù), 并通過命令獨立啟動對應(yīng)的服務(wù), 這可以快速開發(fā)和啟動運行所需的 Mock 服務(wù). 除此之外, 也可以編寫服務(wù)代碼來進行測試. 下面進行簡單舉例:
- 使用 json 配置文件啟動 mock 服務(wù)
# foo.json
[
{
"response" :
{
"text" : "Hello, Moco"
}
}
]
java -jar moco-runner-1.1.0-standalone.jar http -p 12306 -c foo.json
這時訪問 http://localhost:12306/ 將會返回 Hello, Moco。
- 在項目中使用 Moco Java API
除了使用 json 配置文件作為獨立服務(wù)啟動外, 還可以使用 Java API 來啟動 mock 服務(wù), 下面是代碼片段:
首先引入依賴:
<dependency>
<groupId>com.github.dreamhead</groupId>
<artifactId>moco-core</artifactId>
<version>1.1.0</version>
</dependency>
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MockServletContext.class)
public class MockAPITest {
@Test
public void should_response_as_expected() throws Exception {
HttpServer server = httpServer(12307);
server.response("foo");
running(server, new Runnable() {
@Override
public void run() throws IOException {
CloseableHttpResponse response = HttpClients.createDefault().execute(new HttpGet("http://localhost:12307"));
String content = EntityUtils.toString(response.getEntity(), "UTF-8");
assertThat(content, is("foo"));
}
});
}
}
Moco 還支持 HTTPS 和 Socket, 支持與 JUnit 集成等, 詳細內(nèi)容見文檔使用說明。
MockMVC ★
MockMVC 是 spring-boot-starter-test 包自帶的 Mock API,MockMvc 實現(xiàn)了對 Http 請求的模擬,可以方便對 Controller 進行測試,測試速度快、不依賴網(wǎng)絡(luò)環(huán)境,且提供了驗證的工具。下面是具體示例:
HelloController
//HelloController
@RestController
public class HelloController {
@RequestMapping("/hello")
public String index() {
return "Hello World";
}
}
UserController
//UserController
@Slf4j
@RestController
@RequestMapping(value = "/users") // 通過這里配置使下面的映射都在/users下
public class UserController {
// 創(chuàng)建線程安全的Map
static Map<Long, User> users = Collections.synchronizedMap(new HashMap<Long, User>());
@RequestMapping(value = "/", method = RequestMethod.GET)
public List<User> getUserList() {
// 處理"/users/"的GET請求,用來獲取用戶列表
// 還可以通過@RequestParam從頁面中傳遞參數(shù)來進行查詢條件或者翻頁信息的傳遞
List<User> r = new ArrayList<User>(users.values());
return r;
}
@RequestMapping(value = "/", method = RequestMethod.POST)
public String postUser(@ModelAttribute User user) {
// 處理"/users/"的POST請求,用來創(chuàng)建User
// 除了@ModelAttribute綁定參數(shù)之外,還可以通過@RequestParam從頁面中傳遞參數(shù)
users.put(user.getId(), user);
return "success";
}
@RequestMapping(value = "/{id}", method = RequestMethod.GET)
public User getUser(@PathVariable Long id) {
// 處理"/users/{id}"的GET請求,用來獲取url中id值的User信息
// url中的id可通過@PathVariable綁定到函數(shù)的參數(shù)中
return users.get(id);
}
@RequestMapping(value = "/{id}", method = RequestMethod.PUT)
public String putUser(@PathVariable Long id, @ModelAttribute User user) {
// 處理"/users/{id}"的PUT請求,用來更新User信息
User u = users.get(id);
u.setName(user.getName());
u.setAge(user.getAge());
users.put(id, u);
return "success";
}
@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
public String deleteUser(@PathVariable Long id) {
// 處理"/users/{id}"的DELETE請求,用來刪除User
users.remove(id);
return "success";
}
// 測試
@RequestMapping(value = "/postByJson", method = RequestMethod.POST)
public String postByJson(@RequestBody User user, String method) {
log.info("user: {}; method: {}", user, method);
return "success";
}
}
- 單元測試類
HttpMockTest
public class HttpMockTest {
private MockMvc mvc;
private final static ObjectMapper objectMapper = new ObjectMapper();
@Before
public void setUp() throws Exception {
mvc = MockMvcBuilders.standaloneSetup(
new HelloController(),
new UserController()).build();
}
@Test
public void getHello() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().string(equalTo("Hello World")));
}
@Test
public void testUserController() throws Exception {
// 測試UserController
RequestBuilder request = null;
// 1、get查一下user列表,應(yīng)該為空
request = get("/users/");
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(content().string(equalTo("[]")));
// 2、post提交一個user
request = post("/users/")
.param("id", "1")
.param("name", "測試大師")
.param("age", "20");
mvc.perform(request)
.andDo(MockMvcResultHandlers.print())
.andExpect(content().string(equalTo("success")));
// 3、get獲取user列表,應(yīng)該有剛才插入的數(shù)據(jù)
request = get("/users/");
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(content().string(equalTo("[{\"id\":1,\"name\":\"測試大師\",\"age\":20}]")));
// 4、put修改id為1的user
request = put("/users/1")
.param("name", "測試終極大師")
.param("age", "30");
mvc.perform(request)
.andExpect(content().string(equalTo("success")));
// 5、get一個id為1的user
request = get("/users/1");
mvc.perform(request)
.andExpect(content().string(equalTo("{\"id\":1,\"name\":\"測試終極大師\",\"age\":30}")));
// 6、del刪除id為1的user
request = delete("/users/1");
mvc.perform(request)
.andExpect(content().string(equalTo("success")));
// 7、get查一下user列表,應(yīng)該為空
request = get("/users/");
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(content().string(equalTo("[]")));
// 8、json作為參數(shù)
request = post("/users/postByJson")
.param("method", "postByJson")
.content(objectMapper.writeValueAsString(new User(1L, "USER", 23)))
.contentType(MediaType.APPLICATION_JSON);
mvc.perform(request).andExpect(status().is(200))
.andExpect(content().string("success"));
}
}
WireMock ★★
WireMock 是在閱讀 kubernetes-client/java 代碼時發(fā)現(xiàn)的, 在其中有大量使用,它是基于 HTTP API 的 mock 服務(wù)框架,和前面提到的 moco 一樣,它可以通過文件配置以獨立服務(wù)啟動, 也可以通過代碼控制,同時 Spring Cloud Contract WireMock 模塊也使得可以在 Spring Boot 應(yīng)用中使用 WireMock,具體介紹見 Spring Cloud Contract WireMock 。除此之外, WireMock 還提供了在線 mock 服務(wù) MockLab 。下面是 WireMock 在 K8S API 上的示例:
public class K8SApiTest {
@Rule
public WireMockRule wireMockRule = new WireMockRule(8000);
private GenericKubernetesApi<V1Job, V1JobList> jobClient;
ApiClient apiClient;
@Before
public void setup() {
apiClient = new ClientBuilder().setBasePath("http://localhost:" + 8000).build();
jobClient =
new GenericKubernetesApi<>(V1Job.class, V1JobList.class, "batch", "v1", "jobs", apiClient);
}
// test delete
@Test
public void delJob() {
V1Status status = new V1Status().kind("Status").code(200).message("good!");
stubFor(
delete(urlEqualTo("/apis/batch/v1/namespaces/default/jobs/foo1"))
.willReturn(aResponse().withStatus(200).withBody(new Gson().toJson(status))));
KubernetesApiResponse<V1Job> deleteJobResp = jobClient.delete("default", "foo1", null);
assertTrue(deleteJobResp.isSuccess());
assertEquals(status, deleteJobResp.getStatus());
assertNull(deleteJobResp.getObject());
verify(1, deleteRequestedFor(urlPathEqualTo("/apis/batch/v1/namespaces/default/jobs/foo1")));
}
@Test
public void getNs() throws ApiException {
Configuration.setDefaultApiClient(apiClient);
V1Namespace ns1 = new V1Namespace().metadata(new V1ObjectMeta().name("name"));
stubFor(
get(urlEqualTo("/api/v1/namespaces/name"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json")
.withBody(apiClient.getJSON().serialize(ns1))));
CoreV1Api api = new CoreV1Api();
V1Namespace ns2 = api.readNamespace("name", null, null, null);
assertEquals(ns1, ns2);
}
}
DOClever Mock Server ★★
DOClever 集成了 Mock.js,因此自身就是一個 Mock Server,當把接口的開發(fā)狀態(tài)設(shè)置成已完成,本地 Mock 便會自動請求真實接口數(shù)據(jù),否則返回事先定義好的 Mock 數(shù)據(jù),適合 Web 前后端同學進行開發(fā)自測。

Mock 總結(jié)
以上,就是關(guān)于 Mock 技術(shù)以及框架及使用的簡單介紹, 更多詳細用法還需要參考相應(yīng)的文檔或源碼。
關(guān)于 Mock 服務(wù)框架的選擇:
- 首先要基于團隊的技術(shù)棧來選擇,這決定了完成服務(wù)"替身"的速度
- 其次,Mock 要方便快速修改和維護,并能馬上發(fā)揮作用
關(guān)于 Mock 服務(wù)的設(shè)計:
- 首先要簡單
- 其次,處理速度比完美的 Mock 服務(wù)更重要
- 最后,Mock 服務(wù)要能輕量化啟動,并能容易銷毀。
代碼覆蓋率
代碼覆蓋率是指,至少被執(zhí)行了一次的條目數(shù)占整個條目數(shù)的百分比,常被用來衡量測試的充分性和完整性。
常用的三種代碼覆蓋率指標:
- 行覆蓋率: 又稱語句覆蓋率,指已經(jīng)被執(zhí)行到的語句占總可執(zhí)行語句的百分比。
- 判定覆蓋: 又稱分支覆蓋,度量程序中每一個判定的分支是否都被測試到了
- 條件覆蓋: 判定中的每個條件的可能取值至少滿足一次,度量判定中的每個條件的結(jié)果 TRUE 和 FALSE 是否都被測試到了。
代碼覆蓋率的價值
- 根本目的: 找出潛在的遺漏測試用例,并有針對性的進行補充
- 識別出由于需求變更等原因造成的不可達的廢棄代碼
代碼覆蓋率的局限性
- 高的代碼覆蓋率不一定能保證軟件的質(zhì)量,但是低的代碼覆蓋率一定不能能保證軟件的質(zhì)量。如“未考慮某些輸入”以及“未處理某些情況”形成的缺陷。
- 從技術(shù)實現(xiàn)上講,單元測試可以最大化地利用打樁技術(shù)來提高覆蓋率。
- 但在后期,需要付出越來越大的代價,因為需要大量的樁代碼、Mock 代碼和全局變量的配合來控制執(zhí)行路徑。
代碼覆蓋率工具
IDEA 覆蓋率工具
執(zhí)行 xxxTest with Coverage 會進行覆蓋率統(tǒng)計



JaCoCo
JaCoCo 是一款 Java 代碼的主流開源覆蓋率工具,可以很方便地嵌入到 Maven 中,并且和很多主流的持續(xù)集成工具如 Jekins 等以及代碼靜態(tài)檢查工具,都有很好的集成。在上面 IDEA 覆蓋率配置中選擇 Coverage Runner 為 JaCoCo,并導入 pom 依賴后,再運行測試即可得到如下測試覆蓋率報告:
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.2</version>
</dependency>

其它
性能測試和分析
在前面的筋斗云開發(fā)過程中,遇到過幾次反饋接口響應(yīng)較慢的問題,因此,針對這種問題,在最近筋斗云開發(fā)中,開始嘗試學習借助一些性能測試、分析工具來分析代碼具體的執(zhí)行性能,下面給出一些自己的探索:
性能測試 - JMH
JMH(Java Microbenchmark Harness) 是用于代碼微基準測試的工具套件,由 Oracle 內(nèi)部實現(xiàn) JIT 的大牛們編寫,主要是基于方法層面的基準測試,精度可以達到納秒級。當定位到熱點方法,希望進一步優(yōu)化方法性能的時候,可以使用 JMH 對優(yōu)化的結(jié)果進行量化的分析。
下面以 Java 中常見的字符串拼接來進行對比 + 和 StringBuilder 的性能測試對比:
package jvm;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 1)
@Threads(2)
@Fork(1)
@State(value = Scope.Benchmark)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class StringConnectTest {
@Param(value = {"10", "20"})
private int length;
@Benchmark
public void testStringAdd(Blackhole blackhole) {
String a = "";
for (int i = 0; i < length; i++) {
a += i;
}
blackhole.consume(a);
}
@Benchmark
public void testStringBuilderAdd(Blackhole blackhole) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(i);
}
blackhole.consume(sb.toString());
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(StringConnectTest.class.getSimpleName())
.result("result.json")
.resultFormat(ResultFormatType.JSON).build();
new Runner(opt).run();
}
}
測試結(jié)果:
- 運行中:

- 運行結(jié)束:

更多性能測試例子可見 OpenJDK 官方示例
性能分析 - JProfiler
類似于 Go 中 pprof,可以分析 CPU、內(nèi)存等性能。
使用步驟:
- 安裝 IDEA JProfiler Plugin

- 到官網(wǎng)下載安裝相應(yīng)平臺的可執(zhí)行程序

- 檢查 IDEA JProfiler 配置

- 以 JProfiler 啟動程序

- 執(zhí)行請求,選擇 CPU View 觀察代碼執(zhí)行時間

系統(tǒng)性能分析
目前這只在應(yīng)用層面代碼上進行了一些性能分析和調(diào)優(yōu)的探索,隨著項目的深入和學習,未來要繼續(xù)深入系統(tǒng)層面的性能優(yōu)化:
- CPU 性能
- CPU 使用率、僵尸進程、CPU 瓶頸...
- 內(nèi)存性能
- 內(nèi)存分配、內(nèi)存泄露、Buffer、Cache、Swap...
- 網(wǎng)絡(luò)性能
- TCP、HTTP、RPC、網(wǎng)絡(luò)延遲分析...
- I/O 性能
- 磁盤IO、SQL 查詢、Redis、數(shù)據(jù)庫...
Code Review
廣義的單元測試,是這三部分的有機組合:
- Code Review
- 靜態(tài)代碼掃描
- 單元測試用例編寫
Code Review 在單元測試中也起到了很重要的作用。自從參與項目以來,自己的代碼也被成哥和爽哥 review 過幾次,不僅避免了一些小問題導致的 bug,而且從中也學習到了一些內(nèi)容。
同時,也看到組內(nèi)同事棒哥之前也進行過 Code Review 的經(jīng)驗分享,包括 MR 的規(guī)范以及部分代碼示例,其中也提到了 Code Review 來作為單元測試的前提。
另外,Google 也分享了 Code Review 實踐 Google's Engineering Practices documentation - How to do a code review,或許可以借鑒學習。
總之,Code Review 不僅能規(guī)避一些問題,同時還可以互相學習,形成代碼規(guī)范,共同努力提高代碼質(zhì)量。
總結(jié):實際項目中如何開展單元測試
- 不是所有的代碼都要進行單元測試,通常只有底層模塊或者核心模塊的測試中才會采用單元測試
- 確定單元測試框架的選型,這和開發(fā)語言直接相關(guān)
- 引入計算代碼覆蓋率的工具,衡量單元測試的代碼覆蓋率
- 單元測試執(zhí)行、代碼覆蓋率統(tǒng)計和持續(xù)集成流水線做集成,確保每次代碼遞交,都會自動觸發(fā)單元測試,并在單元測試執(zhí)行過程中自動統(tǒng)計代碼覆蓋率,最后 以“單元測試通過率”和“代碼覆蓋率”為標準 來決定本次代碼遞交是否能夠被接受。
參考:
單元測試
- 單元測試維基百科
- 單元測試到底是什么?應(yīng)該怎么做?
- 從頭到腳說單測——談有效的單元測試
- 什么是單元測試?如何做好單元測試?
- 你真的懂測試覆蓋率嗎?
- 在 idea 中使用 JaCoCo 插件統(tǒng)計單元測試覆蓋率
Mock
Moco
MockMVC
- Spring Boot 構(gòu)建 RESTful API 與單元測試
- 微服務(wù)單元測試 Mock 使用與詳解
- 利用 Junit + MockMvc + Mockito 對 Http 請求進行單元測試
Mockito & PowerMock
Wiremock
- WireMock Getting Started
- WireMock and Spring MVC Mocks
- spring-cloud/spring-cloud-contract
- kubernetes-client