Java 中的 UnitTest 和 PowerMock

解牛_馮雅傑發表於2019-01-22

UnitTest 和 PowerMock

學習一門計算機語言,我覺得除了學習它的語法外,最重要的就是要學習怎麼在這個語言環境下進行單元測試,因為單元測試能幫你提早發現錯誤;同時給你的程式加一道防護網,防止你的修改破壞了原有的功能;單元測試還能指引你寫出更好的程式碼,畢竟不能被測試的程式碼一定不是好程式碼;除此之外,它還能增加你的自信,能勇敢的說出「我的程式沒有bug」。

每個語言都有其常用的單元測試框架,本文主要介紹在 Java 中,我們如何使用 PowerMock,來解決我們在寫單元測試時遇到的問題,從 Mock 這個詞可以看出,這類問題主要是解依賴問題。

在寫單元測試時,為了讓測試工作更簡單、減少外部的不確定性,我們一般都會把被測類和其他依賴類進行隔離,不然你的類依賴得越多,你需要做的準備工作就越複雜,尤其是當它依賴網路或外部資料庫時,會給測試帶來極大的不確定性,而我們的單測一定要滿足快速、可重複執行的要求,所以隔離或解依賴是必不可少的步驟。

而 Java 中的 PowerMock 庫是一個非常強大的解依賴庫,下面談到的 3 個特性,可以幫你解決絕大多數問題:

  1. 通過 PowerMock 注入依賴物件
  2. 利用 PowerMock 來 mock static 函式
  3. 輸出引數(output parameter)怎麼 mock

通過 PowerMock 注入依賴物件

假設你有兩個類,MyServiceMyDaoMyService 依賴於 MyDao,且它們的定義如下

// MyDao.java@Mapperpublic interface MyDao { 
/** * 根據使用者 id 檢視他最近一次操作的時間 */ Date getLastOperationTime(long userId);

}// MyService.java@Servicepublic class MyService {
@Autowired private MyDao myDao;
public boolean operate(long userId, String operation) {
Date lastTime = myDao.getLastOperationTime(userId);
// ...
}
}複製程式碼

這個服務提供一個 operate 介面,使用者在呼叫該介面時,會被限制一個操作頻次,所以系統會記錄每個使用者上次操作的時間,通過 MyDao.getLastOperationTime(long userId) 介面獲取,現在我們要對 MyService 類的 operate 做單元測試,該怎麼做?

你可能會想到使用 SpringBoot,它能自動幫我們初始化 myDao 物件,但這樣做卻存在一些問題:

  1. SpringBoot 的啟動速度很慢,這會延長單元測試的時間
  2. 因為時間是一個不斷變化的量,也許這一次你構造的時間滿足測試條件,但下一次執行測試時,可能就不滿足了。

由於以上原因,我們一般在做單元測試時,不啟動 SpringBoot 上下文,而是採用 PowerMock 幫我們注入依賴,對於上面的 case,我們的測試用例可以這樣寫:

// MyServiceTest.java@RunWith(PowerMockRunner.class)@PrepareForTest({MyService.class, MyDao.class
})public class MyServiceTest {
@Test public void testOperate() throws IllegalAccessException {
// 構造一個和當前呼叫時間永遠只差 4 秒的返回值 Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, -4);
Date retTime = calendar.getTime();
// spy 是物件的“部分 mock” MyService myService = PowerMockito.spy(new MyService());
MyDao md = PowerMockito.mock(MyDao.class);
PowerMockito .when(md.getLastOperationTime(Mockito.any(long.class))) .thenReturn(retTime);
// 替換 myDao 成員 MemberModifier.field(MyService.class, "myDao").set(myService, md);
// 假設最小操作的間隔是 5 秒,否則返回 false Assert.assertFalse(myService.operate(1, "test operation"));

}
}複製程式碼

從上面程式碼中,我們首先構造了一個返回時間 retTime,模擬操作間隔的時間為 4 秒,保證了每次執行測試時該條件不會變化;然後我們用 spy 構造一個待測試的 MyService 物件,spymock 的區別是,spy 只會部分模擬物件,即這裡只修改掉 myService.myDao 成員,其他的保持不變。

然後我們定義了被 mock 的物件 MyDao md 的呼叫行為,當 md.getLastOperationTime 函式被呼叫時,返回我們構造的時間 retTime,此時測試環境就設定完畢了,這樣做之後,你就可以很容易的測試 operate 函式了。

利用 PowerMock 來 mock static 函式

上文所說的使用 PowerMock 進行依賴注入,可以覆蓋測試中絕大多數的解依賴場景,而另一種常見的依賴是 static 函式,例如我們自己寫的一些 CommonUtil 工具類中的函式。

還是使用上面的例子,假設我們要計算當前時間和使用者上一次操作時間之間的間隔,並使用 public static long getTimeInterval(Date lastTime) 實現該功能,如下:

// CommonUtil.javaclass CommonUtil { 
public static long getTimeInterval(Date lastTime) {
long duration = Duration.between(lastTime.toInstant(), new Date().toInstant()).getSeconds();
return duration;

}
}複製程式碼

我們的 operator 函式修改如下

// MyService.java// ...    public boolean operate(long userId, String operation) { 
Date lastTime = myDao.getLastOperationTime(userId);
long duration = CommonUtil.getTimeInterval(lastTime);
if (duration >
= 5) {
System.out.println("user: " + userId + " " + operation);
return true;

} else {
return false;

}
}// ...複製程式碼

這裡先從 myDao 獲取上次操作的時間,再呼叫 CommonUtil.getTimeInterval 計算操作間隔,如果小於 5 秒,就返回 false,否則執行操作,並返回 true。那麼我的問題是,如何解掉這裡 static 函式的依賴呢?我們直接看測試程式碼吧

// MyServiceTest.java@PrepareForTest({MyService.class, MyDao.class, CommonUtil.class
})public class MyServiceTest {// ... @Test public void testOperateWithStatic() throws IllegalAccessException {
// ... PowerMockito.spy(CommonUtil.class);
PowerMockito.doReturn(5L).when(CommonUtil.class);
CommonUtil.getTimeInterval(Mockito.anyObject());
// ...
}
}複製程式碼

首先在註解 @PrepareForTest 中增加 CommonUtil.class,依然使用 spy 對類 CommonUtil 進行 mock,如果不這麼做,這個類中所有靜態函式的行為都會發生變化,這會給你的測試帶來麻煩。spy 下面的兩行程式碼你應該放在一起解讀,意為當呼叫 CommonUtil.getTimeInterval 時,返回 5;這種寫法比較奇怪,但卻是 PowerMock 要求的。至此,你已經掌握了 mock static 函式的技巧。

輸出引數(output parameter)怎麼 mock

有些函式會通過修改引數所引用的物件作為輸出,例如下面的這個場景,假設我們的 operation 是一個長時間執行的任務,我們需要不斷輪訓該任務的狀態,更新到記憶體,並對外提供查詢介面,如下程式碼:

// MyTask.java// ...    public boolean run() throws InterruptedException { 
while (true) {
updateStatus(operation);
if (operation.getStatus().equals("success")) {
return true;

} else {
Thread.sleep(1000);

}
}
} public void updateStatus(Operation operation) {
String status = myDao.getStatus(operation.getOperationId());
operation.setStatus(status);

}// ...複製程式碼

上面的程式碼中,run() 是一個輪詢任務,它會不斷更新 operation 的狀態,並在狀態達到 "success" 時停止,可以看到,updateStatus 就是我們所說的函式,雖然它沒有返回值,但它會修改引數所引用的物件,所以這種引數也被稱作輸出引數。

現在我們要測試 run() 函式的行為,看它是否會在 "success" 狀態下退出,那麼我們就需要 mock updateStatus 函式,該怎麼做?下面是它的測試程式碼:

    @Test    public void testUpdateStatus() throws InterruptedException { 
// 初始化被測物件 MyTask myTask = PowerMockito.spy(new MyTask());
myTask.setOperation(new MyTask.Operation());
// 使用 doAnswer 來 mock updateStatus 函式的行為 PowerMockito.doAnswer(new Answer<
Object>
() {
@Override public Object answer(InvocationOnMock invocation) throws Throwable {
Object[] args = invocation.getArguments();
MyTask.Operation operation = (MyTask.Operation)args[0];
operation.setStatus("success");
return null;

}
}).when(myTask).updateStatus(Mockito.any(MyTask.Operation.class));
Assert.assertEquals(true, myTask.run());

}複製程式碼

上面的程式碼中,我們使用 doAnswer 來 mock updateStatus 的行為,相當於使用 answer 函式來替換原來的 updateStatus 函式,在這裡,我們將 operation 的狀態設定為了 "success",以期待 myTask.run() 函式返回 true。於是,我們又學會了如何 mock 具有輸出引數的函式了。

以上程式碼只為了說明應用場景,並非生產環境級別的程式碼,且均通過測試,為方便後續學習,你可以在這裡下載:github.com/jieniu/arti…

參考:

來源:https://juejin.im/post/5c4673fff265da61553b1d83#comment

相關文章