在做 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
static method,用來產生 mock 物件
when/then
為剛剛用 mock 產生的物件,自訂其行為,也就是自訂某些 method 回傳的資料
verify
用來檢查 mock 物件的使用狀況
測試準備
測試前,先製作一個要被 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
檢查是否有呼叫某個 method
檢查呼叫某個 method 的次數
檢查是否沒有使用 mock object
檢查是不是沒有呼叫某個 method
檢查是不是沒有非預期的操作互動 verifyNoMoreInteractions
檢查 呼叫 method 操作的順序
檢查是不是沒有呼叫某個 method
檢查呼叫 method 的次數,至少 或是 最多 幾次
檢查是否有使用某個參數呼叫 method
檢查是否有使用任意參數呼叫 method
利用 argument capture 檢查
@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
CDS: Class Data Sharing 將一組類別預處理為共享存檔文件,然後可以在運行時進行內存映射以減少啓動時間。主要目的是減少啓動時間。應用程式對於它使用的核心類別的數量越小,節省的啓動時間部分就越大。自 JDK 12 開始,就預先打包了一份預設的 CDS 檔案。
解決方式是加上 JVM 執行參數
-Xshare:off
Reference
Mockito - mockito-core 5.12.0 javadoc
Mockito's Mock Methods | Baeldung