單元測試是軟件開發中不可或缺的重要環節,它用于驗證軟件中最小可測試單元的準確性。結合運用Spring Boot、JUnit、Mockito和分層架構,開發人員可以更便捷地編寫可靠、可測試且高質量的單元測試代碼,確保軟件的正確性和質量。
一、介紹
本文將從與單元測試相關的技術主題開始,在技術部分之后,介紹使用Spring Boot、JUnit和Mockito進行單元測試的實踐。
二、測試的關鍵要素
1.單元
單元測試中的單元一詞指的是軟件中可以單獨測試和處理的最小功能部分,通常是指函數、方法、類或模塊等獨立的代碼片段。
2.用例
用例描述了系統使用特定功能或特性的方式,用于理解、設計和測試軟件系統的需求。通常包括用戶如何與系統進行交互、對系統的期望以及應該實現的結果等詳細信息。
3.邊界情況
邊界情況指的是軟件必須處理的特定場景,這些場景包括意外或邊界條件,與典型情況有所不同或被認為是罕見的情況。邊界情況可以包括意外用戶登錄、測試限制、異常輸入或其他可能導致系統錯誤或異常行為的情況。在測試過程中,考慮和測試邊界情況是非常重要的,因為它們可以幫助開發人員發現潛在的問題并確保系統的魯棒性和穩定性。
三、單元測試
單元測試涵蓋了我們可以考慮并編寫的所有可能性。每個單元必須至少有一個測試方法。測試不是為一個方法編寫的,而是為一個單元編寫的。
可以按照以下順序編寫單元測試:正常路徑/用例、邊界情況和異常情況。
這些步驟是必不可少的,這樣做可以確保單元以正確的方式處理輸入,并生成預期的輸出,展現出預期的行為。單元測試是及早發現風險和修復錯誤的最佳方式。通過單元測試,我們可以預防潛在的意外情況,應對生產代碼的變更,確保生產代碼能夠處理各種情況。簡而言之,單元測試確保了生產代碼的安全性。
關于單元測試的另一個重要事項是要測試業務邏輯,不是在單元測試中測試基礎設施代碼,基礎設施代碼可以在集成測試中進行測試。可以考慮使用一些架構模式(如洋蔥架構、六邊形架構等)來將業務邏輯與基礎設施代碼分離。
單元測試的另一個優點是速度快,因為它不需要依賴 Spring ApplicationContext。由于上下文的原因,與單元測試相比,同一測試金字塔中的集成測試速度要慢得多。
1.開始編碼
在分層架構項目中,業務代碼主要位于服務層。這意味著服務層具有單元,需要進行測試。讓我們聚焦于最關鍵的部分。
以下是一段示例代碼:
@Override
public String saveUser(User user) {
validateUser(user);
try {
User savedUser = userRepository.save(user);
return savedUser.getEmAIl();
} catch (Exception exception) {
throw new IllegalArgumentException(E_GENERAL_SYSTEM);
}
}
private void validateUser(User user) {
if (Objects.isNull(user.getEmail())) {
throw new IllegalArgumentException(E_USER_EMAIL_MUST_NOT_BE_NULL);
}
if (findByEmail(user.getEmail()).isPresent()) {
throw new IllegalArgumentException(E_USER_ALREADY_REGISTERED);
}
}
@Override
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
上述代碼中有兩個公共方法和一個私有方法,私有方法可以被視為公共方法的一部分。此外,由于代碼的復雜性和功能需求,還存在許多可能的場景需要編寫多個測試用例來覆蓋各種情況,以確保代碼的正確性。
2.注解
@ExtendWith用于將Mockito庫集成到JUnit測試中。@Test 標記一個方法,使其成為一個測試方法,測試方法包含指定的測試用例,并由 JUnit 自動運行。
在測試過程中,需要模擬正在測試的類的依賴項。之前提到的原因是,由于 Spring ApplicationContext 不會啟動,我們無法將依賴項注入到上下文中。@Mock 用于創建一個模擬的依賴項,而 @InjectMocks 則用于將這些模擬的依賴項注入到被測試類中。
@BeforeEach和@AfterEach可用于在每個方法運行之前和之后執行相應的操作。
@ParameterizedTest 用于使用不同的參數值運行重復的測試用例。通過使用 @ValueSource,可以為方法提供不同的參數值,以便進行多次測試。
3.測試方法的三個主要階段
- Given: 準備測試用例所需的對象
- When: 執行必要的操作以運行測試場景
- Then: 檢查或驗證預期結果
doReturn/when 用于確定在給定指定參數時方法的行為方式。但是,由于依賴項是 @Mock,并不會真正執行。
verify 用于檢查被測試代碼是否按照預期行為執行。如果要測試的方法是 public void 類型,可以使用 verify 進行驗證。
斷言用于驗證預期結果。
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@InjectMocks
private UserServiceImpl userService;
@Mock
private UserRepository userRepository;
private User user;
public static final String MOCK_EMAIL = "[email protected]";
@BeforeEach
void setUp() {
user = new User();
System.out.println("init");
}
@AfterEach
void teardown() {
System.out.println("teardown");
}
@ParameterizedTest
@ValueSource(strings = {"[email protected]", "[email protected]"})
@DisplayName("Happy Path: save user use cases")
void givenCorrectUser_whenSaveUser_thenReturnUserEmail(String email) {
// given
user.setUserName("mertbahardogan").setEmail(email).setPassword("pass");
User savedUser = new User().setEmail(email);
doReturn(savedUser).when(userRepository).save(any());
// when
String savedUserEmail = userService.saveUser(user);
// then
verify(userRepository,times(1)).findByEmail(anyString());
verify(userRepository,times(1)).save(any());
assertEquals(email, savedUserEmail);
}
@Test
@DisplayName("Exception Test: user email must not be null case")
void givenNullUserEmail_whenSaveUser_thenThrowsEmailMustNotNullEx() {
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> userService.saveUser(user));
// then
assertNotNull(exception);
assertEquals(E_USER_EMAIL_MUST_NOT_BE_NULL, exception.getMessage());
}
@Test
@DisplayName("Exception Test: user is already registered case")
void givenRegisteredUser_whenSaveUser_thenThrowsUserAlreadyRegisteredEx() {
// given
user.setEmail(MOCK_EMAIL);
Optional<User> savedUser = Optional.of(new User().setEmail(MOCK_EMAIL));
doReturn(savedUser).when(userRepository).findByEmail(anyString());
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> userService.saveUser(user));
// then
assertNotNull(exception);
assertEquals(E_USER_ALREADY_REGISTERED, exception.getMessage());
}
@Test
@DisplayName("Exception Test: catch case")
void givenIncorrectDependencies_whenSaveUser_thenThrowsGeneralSystemEx() {
// given
user.setEmail(MOCK_EMAIL);
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> userService.saveUser(user));
// then
assertNotNull(exception);
assertEquals(E_GENERAL_SYSTEM, exception.getMessage());
}
@Test
@DisplayName("Happy Path: find user by email")
void givenCorrectUser_whenFindByEmail_thenReturnUserEmail() {
// given
Optional<User> savedUser = Optional.of(new User().setEmail(MOCK_EMAIL));
doReturn(savedUser).when(userRepository).findByEmail(anyString());
// when
Optional<User> user = userService.findByEmail(MOCK_EMAIL);
// then
verify(userRepository,times(1)).findByEmail(anyString());
assertEquals(savedUser, user);
}
}
UserServiceImpl測試類運行時長為1秒693毫秒。