【Spring Boot】第21課-使用 Mockito 進行單元測試

在第9、10課,我們學習過 MockMvc 整合測試。整合測試可能同時涉及多個元件,如 Filter、Service,甚至資料庫等。但我們有時只想單獨測試某個類別的某個方法,這時就會用單元測試(Unit Test)來進行。

本文首先會解說單元測試的概念,接著介紹 Mockito 測試框架。讓我們不需要啟動 Spring 或連上外部服務,也能對單一方法進行測試。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch21-start

一、單元測試的基本概念

單元測試的對象是一個方法,目標是驗證方法的程式邏輯與執行結果是否正確。現在筆者以練習用專案中的 ProductConverter 類別為例,撰寫單元測試。

被測試的方法(以下稱待測方法)是 toProductResponse 方法。它的用途是將資料庫文件(Product)轉換為要展示給 API 呼叫方的格式(ProductResponse)。

public static ProductResponse toProductResponse(Product product) {
ProductResponse response = new ProductResponse();
response.setId(product.getId());
response.setName(product.getName());
response.setPrice(product.getPrice());
response.setCreatorId(product.getCreator());

return response;
}

以下是單元測試。基本上就是準備一個 Product 物件,在轉換完成後,確認各個屬性的值都正確地賦予到 ProductResponse

public class ProductServiceTest {
@Test
public void testConvertProductToResponse() {
Product product = new Product();
product.setId("123");
product.setName("Snack");
product.setPrice(50);
product.setCreator("abc");
ProductResponse productResponse = ProductConverter.toProductResponse(product);

Assert.assertEquals(product.getId(), productResponse.getId());
Assert.assertEquals(product.getName(), productResponse.getName());
Assert.assertEquals(product.getPrice(), productResponse.getPrice());
Assert.assertEquals(product.getCreator(), productResponse.getCreatorId());
}
}

以上是針對靜態方法的單元測試 ,若想測試一個物件的某個方法,也能比照這個方式進行。


二、Mockito 單元測試

接著讓我們推展到 Spring 元件的單元測試。先前學習的整合測試,從發出 request 到取得 response,途中會經過多個元件與服務的處理。若是其中一個部份有問題,例如連接不上服務,或呼叫的其他元件有地方寫錯,這時很有可能影響測試結果。

為了排除這些影響,對元件方法的邏輯進行單元測試時,我們需要「假設」待測方法所呼叫的其他元件與服務是正確運作的。接下來將引進一個叫做「Mockito」的測試框架。它能讓我們直接定義某個物件的方法接收到什麼參數,就回傳指定的值或拋出例外。這個定義的動作,稱為「模擬」(mock)。

Spring Boot 的測試函式庫已經內含 Mockito,所以不用再另外匯入了。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

本節筆者以 ProductService 的 getProduct 方法為例,示範單元測試。以下是 getProduct 方法的程式碼,它會從資料庫(repository)找出對應 id 的 Product 文件。

public Product getProduct(String id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException("Can't find product."));
}

以下開始撰寫單元測試。由於待測方法只會用到 ProductRepository 這個元件,因此建構 ProductService 時,只需準備 ProductRepository 的模擬物件。使用 mock 方法,傳入類別或介面的 class,即可得到一個模擬物件。我們可以對它模擬想要的行為。

public class ProductServiceTest {
@Test
public void testGetProduct() {
String productId = "123";
Product testProduct = new Product();
testProduct.setId(productId);
testProduct.setName("Snack");
testProduct.setPrice(50);
testProduct.setCreator("abc");

ProductRepository productRepository = mock(ProductRepository.class);
when(productRepository.findById(productId))
.thenReturn(Optional.of(testProduct));
ProductService productService = new ProductService(productRepository, null);
// TODO
}
}

以上的範例程式,首先會建立一個叫做 testProductProduct 物件。接著利用 whenthenReturn 方法進行模擬。行為是當 ProductRepository 的 findById 方法接收「123」這個字串時,要回傳 testProduct

其中 when 方法接收的參數,是模擬物件的整個方法呼叫,請不要拆開。而 thenReturn 方法接收的參數,是要讓模擬物件的方法回傳的值。其傳入的參數形態要與被模擬方法所宣告的相同,故此處包裝成 Optional

最後實際呼叫 ProductServicegetProduct 方法,並確認回傳值的各個屬性值與 testProduct 相同。

@Test
public void testGetProduct() {
// 其餘略過

Product resultProduct = productService.getProduct(productId);
Assert.assertEquals(testProduct.getId(), resultProduct.getId());
Assert.assertEquals(testProduct.getName(), resultProduct.getName());
Assert.assertEquals(testProduct.getPrice(), resultProduct.getPrice());
Assert.assertEquals(testProduct.getCreator(), resultProduct.getCreator());
}

以上就是基本的 Mockito 單元測試。即便開發時遇到一些原因,連不上真實的資料庫,但在測試方法邏輯時,仍可透過模擬物件假設它有正常運作。

附帶一提,thenReturn 方法的參數也接受陣列,讓我們能模擬出第一次、第二次、第三次等不同次數要回傳的值。

再來就是透過 when 方法模擬完行為後,該行為只會在呼叫元件時生效。也就是說,假設同一個元件的 A、B 方法都被模擬,則當 A 方法呼叫 B 方法時,B 方法的行為並不會是當初模擬時定義的,而是會直接執行 B 方法的邏輯。

三、驗證元件有被呼叫

第三、四節會針對 ProductServicecreateProduct 方法,撰寫單元測試,藉此認識其他驗證方式。以下是 createProduct 的程式碼,它會將傳入的 ProductRequest 轉為代表資料庫文件的 Product 物件,接著賦予建立者的 id 後儲存。最後轉換為 ProductResponse 的格式後回傳。

public ProductResponse createProduct(ProductRequest request) {
Product product = ProductConverter.toProduct(request);
product.setCreator(userIdentity.getId());
repository.insert(product);

return ProductConverter.toProductResponse(product);
}

以下開始撰寫單元測試。此處需要 ProductRepositoryUserIdentity 兩個元件。另外還定義 UserIdentitygetId 方法要回傳「abc」的字串,代表目前登入使用者的 id。

@Test
public void testCreateProduct() {
ProductRepository productRepository = mock(ProductRepository.class);
UserIdentity userIdentity = mock(UserIdentity.class);

String creatorId = "abc";
when(userIdentity.getId())
.thenReturn(creatorId);
ProductService productService = new ProductService(productRepository, userIdentity);
// TODO
}

接著建立 ProductRequest 物件,傳入 createProduct 方法進行測試。

@Test
public void testCreateProduct() {
// 其餘略過

ProductRequest productReq = new ProductRequest();
productReq.setName("Snack");
productReq.setPrice(50);
ProductResponse productRes = productService.createProduct(productReq);

verify(userIdentity).getId();
verify(productRepository).insert(any(Product.class));

Assert.assertEquals(productReq.getName(), productRes.getName());
Assert.assertEquals(productReq.getPrice().intValue(), productRes.getPrice());
Assert.assertEquals(creatorId, productRes.getCreatorId());
}

createProduct 方法執行完畢後,此處使用 verify 方法驗證模擬物件的某個方法是否有執行。包含 UserIdentitygetId 方法與 ProductRepositoryinsert 方法。最後確認回傳的 ProductResponse 的各個屬性值與 ProductRequest 相同。

在過程中,驗證 ProductRepository 時使用了 any 方法。其意義為呼叫 insert 方法時,傳入任何 Product 物件都可以。若要驗證是否傳入我們所期望的物件,讀者也能將 any 方法替代為自行指定的物件。


四、驗證呼叫次數與順序

我們還能進一步驗證這些模擬物件的呼叫次數與順序。下面的例子是故意執行多次 createProduct 方法,驗證 UserIdentityProductRepository 的執行次數。

@Test
public void testCreateProduct() {
// 其餘略過
int count = 3;
ProductResponse productRes = null;
for (int i = 0; i < count; i++) {
productRes = productService.createProduct(productReq);
}

verify(userIdentity, times(count)).getId();
verify(productRepository, times(count)).insert(any(Product.class));
// 其餘略過
}

只要在 verify 的方法參數中,額外傳入 times 方法與期望次數,就能驗證這個模擬物件的特定方法應該執行幾次。此處的例子是期望執行3次。

若想驗證模擬物件的方法執行順序,則先使用 inOrder 方法取得 InOrder 物件,並傳入要驗證的模擬物件。接著按照期望的順序,依序呼叫 InOrderverify 方法即可。

@Test
public void testCreateProduct() {
// 其餘略過
ProductResponse productRes = productService.createProduct(productReq);

InOrder inOrder = inOrder(productRepository, userIdentity);
inOrder.verify(userIdentity).getId();
inOrder.verify(productRepository).insert(any(Product.class));
// 其餘略過
}

五、對有標記的元件進行模擬

有時候專案中的元件可能沒有建構子,而是透過 @Service@Component 等元件標記來建立,並搭配 @Autowired 標記完成依賴注入(這邊指的是 field injection)。對於這樣子的元件,我們要用另一種方式來模擬它的行為。

本節針對 SpringUserServiceloadUserByUsername 方法撰寫單元測試。以下是 loadUserByUsername 的程式碼。它會呼叫 AppUserService 元件,找出代表資料庫文件的 AppUser 物件,包裝成 SpringUser 後回傳。

@Service
public class SpringUserService implements UserDetailsService {
@Autowired
private AppUserService appUserService;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
AppUser appUser = appUserService.getUserByEmail(username);
return new SpringUser(appUser);
} catch (NotFoundException e) {
throw new UsernameNotFoundException("Username is wrong.");
}
}
}

以下先準備測試類別。此處需要將元件宣告為測試類別的全域變數,並利用 @Mock@InjectMocks 標記建立出模擬物件。在這個測試當中,被依賴的元件是 AppUserService,而待測元件是 SpringUserService,分別冠上前述兩個標記。最後在類別加上 @RunWith 標記,讓那兩個標記生效。

@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
@Mock
private AppUserService appUserService;

@InjectMocks
private SpringUserService springUserService;
}

以下開始撰寫單元測試。在測試案例中,我們按照先前的做法進行模擬。首先建立一個 AppUser 物件,並定義 AppUserService 在接收「vincent@gmail.com」這個字串時,要回傳該 AppUser

@Test
public void testLoadSpringUser() {
String email = "vincent@gmail.com";
AppUser appUser = new AppUser();
appUser.setId("123");
appUser.setEmailAddress(email);
appUser.setName("Vincent Zheng");

when(appUserService.getUserByEmail(email))
.thenReturn(appUser);

SpringUser springUser = (SpringUser) springUserService.loadUserByUsername(email);

Assert.assertEquals(appUser.getId(), springUser.getId());
Assert.assertEquals(appUser.getName(), springUser.getName());
Assert.assertEquals(appUser.getEmailAddress(), springUser.getUsername());
}

最後實際呼叫 loadUserByUsername 方法,確認所回傳 SpringUser 的屬性與 AppUser 相同。


六、讓模擬物件拋出例外

本節將解說如何在定義模擬物件的行為時,讓它拋出例外。這邊同樣以上一節的 SpringUserServiceloadUserByUsername 方法為例子。根據該方法的實作邏輯,若找不到使用者,最後將會拋出 UsernameNotFoundException,這就是要測試的地方了。

以下開始撰寫單元測試。我們知道 AppUserServicegetUserByEmail 方法在找不到使用者時,會拋出 NotFoundException,所以要對這部分進行模擬。首先一樣使用 when 方法,並傳入整個方法呼叫。此處使用了 anyString 方法,代表傳入任何字串,其行為都相同。

先前對模擬物件定義回傳值時,是使用 thenReturn 方法。現在要拋出例外,則是使用 thenThrow 方法,並傳入要拋出的例外物件。

@Test(expected = UsernameNotFoundException.class)
public void testLoadSpringUserButNotFound() {
when(appUserService.getUserByEmail(anyString()))
.thenThrow(new NotFoundException());

springUserService.loadUserByUsername("vincent@gmail.com");
}

最後實際呼叫 loadUserByUsername 方法,確認有拋出預期的例外。

若是想模擬沒有回傳值的方法拋出例外,要用另一種做法。以下兩個例子是給 AppUser 設定 authorities 屬性,當給予 null 值或空 List 時要拋出 IllegalArgumentException

@Test(expected = IllegalArgumentException.class)
public void testSetAuthoritiesAsNull() {
AppUser user = mock(AppUser.class);
doThrow(new IllegalArgumentException())
.when(user).setAuthorities(isNull());

user.setAuthorities(null);
}

@Test(expected = IllegalArgumentException.class)
public void testSetAuthoritiesAsEmpty() {
AppUser user = mock(AppUser.class);
doThrow(new IllegalArgumentException())
.when(user).setAuthorities(Collections.emptyList());

user.setAuthorities(new ArrayList<>());
}

模擬的方式是使用 doThrow 方法,傳入要拋出的例外物件。接著使用 when 方法,傳入模擬物件。最後呼叫要模擬的方法,定義拋出例外的條件。這兩個測試分別定義當傳入值是 null 或空 List 時,要拋出例外。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch21-fin

上一篇:【Spring Boot】第20課-切面導向程式設計(AOP)

下一篇:【Spring Boot】第22.1課-使用 RestTemplate 存取外部 API

留言

  1. @Test(expected = UsernameNotFoundException.class)
    public void testLoadSpringUserButNotFound() {
    when(appUserService.getUserByEmail(anyString()))
    .thenThrow(new NotFoundException());

    springUserService.loadUserByUsername("vincent@gmail.com");
    }  

    原本是想要拋出.thenTrow(new UsernameNotFoundException)嗎?
    因為NotFoundException是404好像跟expected寫的不同 ?

    回覆刪除
    回覆
    1. 那邊的測試程式的確是「thenThrow(new NotFoundException())」,沒問題喔
      請回頭看看本文第五節的 SpringUserService.loadUserByUsername 方法,那邊的例外處理在接到 NotFoundException 後,會再拋出 UsernameNotFoundException 給測試程式~

      刪除
    2. 看到了! 之前學SpringSecurity章節那邊 有些方法被廢棄了,就跑去學其他教學,
      然後忽略了它,原來如此,有意思 ,謝謝回覆。

      刪除

張貼留言