在做 Java 專案測試時,常見的方法是使用 JUnit 測試框架。但在實際專案測試時,最常遇到的問題是,準備測試資料。專案程式通常都會連接資料庫,然後透過商業邏輯或一些運算,再將結果存回資料庫。
這在測試時就會遇到很多問題,例如測試案例需要某些特殊的使用者在某個特別的狀態,例如未付款的狀態,然後程式經過了某些付款程序,讓資料變成已付款狀態,又存回資料庫,更新了這個使用者的狀態。這裡遇到的問題是,沒辦法一直產生未付款狀態的使用者,這就導致每一次要測試付款程序時,就要重新準備一次測試資料。
如果該資料跟其他的測試有相依性,又讓這個問題更複雜了。
在使用 JUnit 時,常常會遇到測試的對象裡面因為包含了其他類別的物件,需要先準備/建立這些前置的物件,才能真正地去測試現在想要測試的對象。例如專案中的商業邏輯程式需要使用到資料庫的連線,就必須確實準備一個資料庫,並建立該 DAO 物件,然後才能進行測試。
這跟剛剛提到的測試資料是類似的問題,就是外部資料相依性問題。
假設目前的類別關係如下
flowchart LR
A --> B --> D & E
A --> C
因為 A 相依於 B 與 C,故這時候需要製作 B 與 C 的 mock 物件,用來作 A 的測試
flowchart LR
A --> B[mock of B]
A --> C[mock of C]
# TDD/BDD and Test Double 這篇文章提到 unit test 必須要
是最小的測試單位
一個案例只能測試一個方法
測試案例之間沒有相依性
沒有外部相依性
不具備邏輯
Mockito 就是用來輔助 JUnit,製作 mock 物件,也就是達成上面所說的沒有外部相依性,也可以讓測試案例之間沒有相依性。Mock 模擬對象 是一種模擬真實對象行為的假的物件,這個假物件可以用來測試其他程式的行為。
單元測試之 mock/stub/spy/fake ? 這邊提到了幾個名詞的差異
mock
模擬的假物件,可讓程式使用 mock 物件驗證商業邏輯或是互動是否正確。mock 有可能會造成測試失敗
stub
也是假物件,但有點替身的意思,跟 mock 類似,是取代真實物件的假物件,使用時,該替身不會造成測試失敗
fake
完全不做事情的假物件,測試僅僅會經過這些物件,但不會做任何驗證,不會造成測試失敗,也就是 stub 的意思
dummy
空物件,只用來填補缺少的參數,或是其他已經測試完成的物件,測試僅僅會經過這些物件,但不會做任何驗證,不會造成測試失敗,也就是 stub 的意思
spy
通常 mock 是製作整個假物件,而 spy 只會偽造類別裡面的某些 method,如果針對該偽造的方法有做驗證測試,就將 spy 視為 mock。如果沒有驗證,那就視為 stub
Mockito
使用 mockito 基本需要了解這三個部分
測試準備
測試前,先製作一個要被 mock 的類別
public class DataDAO {
public String getDataById(String id) {
return id;
}
public int getDataSize() {
return 0;
}
public boolean add(String data) {
return true;
}
public void clear() {
}
}
引用 libary
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-core -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-inline -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.9.1</version>
<scope>compile</scope>
</dependency>
mock
用類別定義產生 mock object
public static <T> T mock(Class<T> classToMock)
用類別名稱產生 mock object 後,指定這個 mock object 的名稱
public static <T> T mock(Class<T> classToMock, String name)
產生 mock object,自訂 Answer
public static <T> T mock(Class<T> classToMock, Answer defaultAnswer)
產生 MockSettings 自訂 Answer
private String randomId() {
return UUID.randomUUID().toString();
}
@Test
public void mock_test1() {
// public static <T> T mock(Class<T> classToMock)
// 用類別定義產生 mock object
// 利用 mock 產生 DataDAO 的 mock object
DataDAO dataDAOmock = mock(DataDAO.class);
// 當透過這個 mock object 呼叫 add method 時,永遠回傳 false
when(dataDAOmock.add(anyString())).thenReturn(false);
boolean added = dataDAOmock.add( randomId() );
// verify 可檢查是否有呼叫 add method
verify(dataDAOmock).add(anyString());
// 以 JUnit 檢查 add method 的 return value
assertFalse(added);
}
@Test
public void mock_test2_name() {
// public static <T> T mock(Class<T> classToMock, String name)
// 用類別名稱產生 mock object 後,指定這個 mock object 的名稱
// 利用 mock 產生 DataDAO 的 mock object
DataDAO dataDAOmock = mock(DataDAO.class, "test2DataDAOMock");
// 當透過這個 mock object 呼叫 add method 時,永遠回傳 false
when(dataDAOmock.add(anyString())).thenReturn(false);
boolean added = dataDAOmock.add( randomId() );
TooFewActualInvocations exception = assertThrows(TooFewActualInvocations.class, () -> {
verify(dataDAOmock, times(2)).add(anyString());
});
// exception.printStackTrace();
// rg.mockito.exceptions.verification.TooFewActualInvocations:
// test2DataDAOMock.add(<any string>);
// Wanted 2 times:
//-> at mock.DataDAO.add(DataDAO.java:13)
// But was 1 time:
//-> at mock.DataDAOMockitoTest.test2(DataDAOMockitoTest.java:41)
// ......
assertTrue(exception.getMessage().contains("test2DataDAOMock.add"));
}
static class CustomAnswer implements Answer<Boolean> {
@Override
public Boolean answer(InvocationOnMock invocation) throws Throwable {
return false;
}
}
@Test
public void mock_test3_answer() {
// public static <T> T mock(Class<T> classToMock, Answer defaultAnswer)
// 產生 mock object,自訂 Answer
DataDAO dataDAOmock = mock(DataDAO.class, new CustomAnswer());
boolean added = dataDAOmock.add( randomId() );
verify(dataDAOmock).add(anyString());
assertFalse(added);
}
@Test
public void mock_test4_MockSettings() {
// 產生 MockSettings 自訂 Answer
MockSettings customSettings = withSettings().defaultAnswer(new CustomAnswer());
DataDAO dataDAOmock = mock(DataDAO.class, customSettings);
boolean added = dataDAOmock.add( randomId() );
verify(dataDAOmock).add(anyString());
assertFalse(added);
}
when/then
當透過這個 mock object 呼叫 method 時,永遠回傳某個值
用 doReturn 方式設定 return 的結果
設定 method 呼叫時,會 throw Excpetion
設定 void return 的 method,會 throw Exception
設定 method 多次呼叫時,有不同的 return 結果
設定 spy 的行為,spy 是對一部分的 method 做 mock
設定呼叫 mock 的某個 method 要呼叫真實的物件的 method
自訂 Answer
@Test
public void when_test1() {
// 利用 mock 產生 DataDAO 的 mock object
DataDAO dataDAOmock = mock(DataDAO.class);
// 當透過這個 mock object 呼叫 add method 時,永遠回傳 false
when(dataDAOmock.add(anyString())).thenReturn(false);
boolean added = dataDAOmock.add( randomId() );
assertFalse(added);
// 用另一種方式設定 return 的結果
doReturn(false).when(dataDAOmock).add(anyString());
boolean added2 = dataDAOmock.add( randomId() );
assertFalse(added2);
// 設定 method 呼叫時,會 throw Excpetion
when(dataDAOmock.add(anyString())).thenThrow(IllegalStateException.class);
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
dataDAOmock.add( randomId() );
});
// 設定 void return 的 method,會 throw Exception
doThrow(NullPointerException.class).when(dataDAOmock).clear();
assertThrows(NullPointerException.class, () -> dataDAOmock.clear());
// 設定 method 多次呼叫時,有不同的 return 結果
DataDAO dataDAOmock2 = mock(DataDAO.class);
when(dataDAOmock2.add(anyString()))
.thenReturn(false)
.thenThrow(IllegalStateException.class);
assertThrows(IllegalStateException.class, () -> {
dataDAOmock2.add( randomId() );
dataDAOmock2.add( randomId() );
});
// 設定 spy 的行為
// mock 是接管所有物件的 method,但 spy 則是對一部分的 method 做 mock
DataDAO dataDAO = new DataDAO();
DataDAO spy = spy(dataDAO);
doThrow(NullPointerException.class).when(spy).getDataSize();
assertThrows(NullPointerException.class, () -> spy.getDataSize());
assertEquals("test", spy.getDataById("test"));
// 設定呼叫 mock 的某個 method 要呼叫真實的物件的 method
DataDAO dataDAOmock3 = mock(DataDAO.class);
when(dataDAOmock3.getDataSize()).thenCallRealMethod();
assertEquals( 0, dataDAOmock3.getDataSize());
// 自訂 Answer
doAnswer(invocation -> "Always the same").when(dataDAOmock3).getDataById(anyString());
String data = dataDAOmock3.getDataById("1");
assertEquals("Always the same", data);
}
verify
@Test
public void verify_test1() {
DataDAO dataDAOmock = mock(DataDAO.class);
dataDAOmock.getDataSize();
// 檢查是否有呼叫某個 method
verify(dataDAOmock).getDataSize();
// 檢查呼叫某個 method 的次數
verify(dataDAOmock, times(1)).getDataSize();
DataDAO dataDAOmock2 = mock(DataDAO.class);
// 檢查是否沒有使用 mock object
verifyNoInteractions(dataDAOmock2);
// 檢查是不是沒有呼叫某個 method
verify(dataDAOmock2, times(0)).getDataSize();
// 檢查是不是沒有非預期的操作互動 verifyNoMoreInteractions
DataDAO dataDAOmock3 = mock(DataDAO.class);
dataDAOmock3.getDataSize();
dataDAOmock3.clear();
verify(dataDAOmock3).getDataSize();
assertThrows(NoInteractionsWanted.class, () -> verifyNoMoreInteractions(dataDAOmock3));
// 檢查 呼叫 method 操作的順序
DataDAO dataDAOmock4 = mock(DataDAO.class);
dataDAOmock4.getDataSize();
dataDAOmock4.add("a parameter");
dataDAOmock4.clear();
InOrder inOrder = Mockito.inOrder(dataDAOmock4);
inOrder.verify(dataDAOmock4).getDataSize();
inOrder.verify(dataDAOmock4).add("a parameter");
inOrder.verify(dataDAOmock4).clear();
// 檢查是不是沒有呼叫某個 method
verify(dataDAOmock4, never()).getDataById("");
// 檢查呼叫 method 的次數,至少 或是 最多 幾次
DataDAO dataDAOmock5 = mock(DataDAO.class);
dataDAOmock5.clear();
dataDAOmock5.clear();
dataDAOmock5.clear();
verify(dataDAOmock5, atLeast(1)).clear();
verify(dataDAOmock5, atMost(5)).clear();
// 檢查是否有使用某個參數呼叫 method
DataDAO dataDAOmock6 = mock(DataDAO.class);
dataDAOmock6.getDataById("test1");
verify(dataDAOmock6).getDataById("test1");
assertThrows(WantedButNotInvoked.class, () -> verify(dataDAOmock6).getDataById("test"));
// 檢查是否有使用任意參數呼叫 method
verify(dataDAOmock6).getDataById(anyString());
// 利用 argument capture 檢查
DataDAO dataDAOmock7 = mock(DataDAO.class);
dataDAOmock7.getDataById("someElement");
ArgumentCaptor<String> argumentCaptor = ArgumentCaptor.forClass(String.class);
verify(dataDAOmock7).getDataById(argumentCaptor.capture());
String capturedArgument = argumentCaptor.getValue();
assertEquals( "someElement", capturedArgument);
}
Note
在執行測試時,有遇到這樣的錯誤資訊
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
原因是 VM warning: Sharing is only supported for boot loader classes · Issue #3111 · mockito/mockito · GitHub
CDS: Class Data Sharing 將一組類別預處理為共享存檔文件,然後可以在運行時進行內存映射以減少啓動時間。主要目的是減少啓動時間。應用程式對於它使用的核心類別的數量越小,節省的啓動時間部分就越大。自 JDK 12 開始,就預先打包了一份預設的 CDS 檔案。
解決方式是加上 JVM 執行參數
-Xshare:off
Reference
Mockito - 維基百科,自由的百科全書
Mockito - mockito-core 5.12.0 javadoc
# SpringBoot - 單元測試工具 Mockito
Mockito 简明教程| waylau.com
Mockito's Mock Methods | Baeldung
Mockito When/Then Cookbook | Baeldung
Mockito Verify Cookbook | Baeldung