接續前一篇進行 Service 的測試,我們接序同一個情境針對 Product 的 CRUD,但是應用不同的寫法, 這邊會運用到 Mockito 這個套件來幫助我們應用 Mock 的虛構物件進行測試。

關於 Mock

  • 顧名思義 Mock 有模擬、假的意思,將這樣模擬概念應用到測試中,就是希望程式原先依賴的對象都可以透過 mock 方式建立一個假的對象去模擬真實行為。
  • 先前介紹單元測試時,有提到特性是應該各單元測試互相獨立,不依賴其他外部系統,Mock Test 是一種單元測試方法,能專注於要測試的程式本身的運作中,避免測試一個方法卻要建構整個 bean 相關的依賴架構。

一個系統架構會有多個類別之間互相依賴,且在 Spring Boot 中會都註冊成為 Bean,如下圖這樣多個 Class 互相依賴,Class A 需要依賴 B 和 C

https://ithelp.ithome.com.tw/upload/images/20241001/201509778lUjZPHQvZ.png

引入 mock 測試時,就可以創建假的對象類,替換掉真實的 Class B 和 C,並可以自己設定這個 mock 對象的參數和期望結果,讓我們可以專注在測試當前的 Class A 應該要得到的回傳或是要 Pass 的結果,不受其他的外部服務影響,就能提高測試效率並專注當前測試 Class 的運作。

https://ithelp.ithome.com.tw/upload/images/20241001/20150977LbyWkqvCCD.png

Mockito 套件

Java 的 mock 測試框架, Java 中目前主流的 mock 測試工具有 Mockito、JMock、EasyMock..等, SpringBoot 目前內建的是使用 Mockito 框架。和先前 Junit 一樣,只要引用 spring-boot -starter- test 這個 dependency 就包含在裡面。

測試重點過程及用法

  • 建立模擬物件:Mockito 可以建立模擬類別,並且標示模擬類被注入位置
  • 控制行為:可定義模擬物件行為,如方法返回值、拋出異常等,且都是我們預期的行為,不須真實運作,可以避免實體運作帶來的影響(資料庫操作資料或實體當下無法運作)
  • 驗證互動:可以驗證模擬物件是如我們的預期進行控制行為,驗證互動次數、觸發順序等等。

模擬物件建立

@Mock :標註建立一個模擬物件,通常為資料庫溝通區塊(Dao)或是目前不希望調用真實實體的物件

@InjectMocks :標註模擬物件注入位置,如果今天要測試 Service 會應用到某個 Dao 來進行資料溝通,就是註記在該 Service 類別上,就會把 @Mock 的物件注入

控制行為

使用 when() 這個方法可以控制當特定方法觸發時回傳我們要的結果。

下面是幾種常用的寫法:

  • 一般驗證:

when(方法).thenReturn(自訂回傳結果)

  • 拋出 Exception 驗證:

when(方法).thenThrow(Exception Class)

  • 沒有回傳驗證:

doNothing().when(方法)

驗證互動

使用 verify() 來驗證模擬對象的方法是否按照預期被調用。

下面是幾種常用的寫法:
verify(被呼叫類, times(呼叫次數)).被呼叫類使用方法()

如果不寫 times 就是預設驗證被呼叫類呼叫使用方法一次

上面控制和驗證如果需要限制為特定值或是特定類別

when(productRepository.save(any(Product.class))).thenReturn(product);

如果 mock 會被呼叫到的方法有順序驗證,可使用 inOrder 物件來讓 mockito 依照順序驗證

先指定需要驗證的 mock 物件裝入 inOrder,再透過 inOrder.verify() 來進行,類似作法如下

1
2
3
4
InOrder inOrder = inOrder(productTestDao);

inOrder.verify(productTestDao, times(1)).findById(id);
inOrder.verify(productTestDao, times(1)).save(any(ProductTest.class));

Mockito 使用注意

  • 不能 mock 靜態方法
  • 不能 mock private 方法
  • 不能 mock final class

Mock 測試應用

如果以先前我們測試 ProductService 的部分來畫一個概念圖,原本要測試需要@Autowired ProductDao,並且會直接更動資料庫,如果應用 Mock 將 ProductDao 模擬成 MockProductDao 來模擬和資料庫溝通的操作或回傳,就可以來協助我們進行這些行為的測試。

https://ithelp.ithome.com.tw/upload/images/20241001/20150977WKRi57l9ip.png

Mockito 引入測試有兩種寫法:

  1. 測試類要加上 @ExtendWith(MockitoExtension.class)
  2. 每個測試類都要初始化,所以可以用 @BeforeEach 配合初始化 MockitoAnnotations.*openMocks*(this)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// 使用 @ExtendWith 或 @BeforeEach 其中之一來初始化 MockitoExtension
// @ExtendWith(MockitoExtension.class)
public class ProductTestServiceTest {

@Mock
private ProductTestDao productTestDao;

@InjectMocks
private ProductTestService productTestService;

@BeforeEach
public void setup() {
MockitoAnnotations.openMocks(this);
}

@Test
public void testReadMockProduct() {
// mockProduct
ProductTest mockProduct = new ProductTest();
mockProduct.setId(1L);
mockProduct.setName("Switch 2");
mockProduct.setPrice(11999.99);
mockProduct.setDescription("任天堂熱門的手持遊戲機");

when(productTestDao.findById(1L)).thenReturn(Optional.of(mockProduct));

Optional<ProductTest> product = productTestService.getProductById(1L);
assertTrue(product.isPresent());
assertEquals(11999.99, product.get().getPrice());
assertEquals("任天堂熱門的手持遊戲機", product.get().getDescription());
verify(productTestDao, times(1)).findById(1L);
}

@Test
public void testUpdateMockProduct() {
Long id = 2L;
ProductTest mockProduct = new ProductTest();
mockProduct.setId(id);
mockProduct.setName("PlayStation 5 pro discount");
mockProduct.setPrice(21999.99);
mockProduct.setDescription("索尼的次世代遊戲主機 - 降價促銷");

when(productTestDao.findById(id)).thenReturn(Optional.of(mockProduct));
when(productTestDao.save(any(ProductTest.class))).thenReturn(mockProduct);
ProductTest updatedMockProduct = productTestService.updateProductById(id, mockProduct);

assertNotNull(updatedMockProduct);
assertEquals(id, updatedMockProduct.getId());
assertEquals("PlayStation 5 pro discount", updatedMockProduct.getName());
assertEquals(21999.99, updatedMockProduct.getPrice());

InOrder inOrder = inOrder(productTestDao);

inOrder.verify(productTestDao, times(1)).findById(id);
inOrder.verify(productTestDao, times(1)).save(any(ProductTest.class));
}

@Test
public void testDeleteMockProduct() {
doNothing().when(productTestDao).deleteById(1L);
productTestService.deleteProductById(1L);
verify(productTestDao, times(1)).deleteById(1L);
}

@Test
public void testCreateMockProduct() {
// 新增一個新的產品
ProductTest mockProduct = new ProductTest();
mockProduct.setId(6L);
mockProduct.setName("三星 Galaxy S22");
mockProduct.setPrice(20999.99);
mockProduct.setDescription("三星旗艦智慧型手機");

when(productTestDao.save(mockProduct)).thenReturn(mockProduct);

ProductTest savedProduct = productTestService.saveProduct(mockProduct);

assertNotNull(savedProduct);
assertNotNull(savedProduct.getId());
assertEquals("三星 Galaxy S22", savedProduct.getName());
}
}

參考資料: