【注】本文譯自: Unit Testing with Spring Boot - Reflectoring
編寫好的單元測試可以被認為是一門難以掌握的藝術。但好消息是支持它的機制很容易學習。
本教程為您提供了這些機制,並詳細介紹了編寫良好的單元測試所必需的技術細節,重點是 Spring Boot 應用程序。
我們將看看如何以可測試的方式創建 Spring bean,然后討論 Mockito 和 AssertJ 的用法,這兩個庫默認包含在 Spring Boot 中用於測試。
請注意,本文僅討論單元測試。集成測試、Web 層測試和持久層測試將在本系列的后續文章中討論。
代碼示例
本文附有 GitHub 上 的工作代碼示例。
依賴關系
對於本教程中的單元測試,我們將使用 JUnit Jupiter (JUnit 5)、Mockito 和 AssertJ。我們還將包括 Lombok 以減少一些樣板代碼:
dependencies {
compileOnly('org.projectlombok:lombok')
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}
Mockito 和 AssertJ 是使用 spring-boot-starter-test
依賴項自動導入的,但我們必須自己包含 Lombok。
不要在單元測試中使用 Spring
如果你以前用 Spring 或 Spring Boot 寫過測試,你可能會說我們不需要 Spring 來寫單元測試。這是為什么?
考慮以下測試 RegisterUseCase
類的單個方法的“單元”測試:
@ExtendWith(SpringExtension.class)
@SpringBootTest
class RegisterUseCaseTest {
@Autowired
private RegisterUseCase registerUseCase;
@Test
void savedUserHasRegistrationDate() {
User user = new User("zaphod", "zaphod@mail.com");
User savedUser = registerUseCase.registerUser(user);
assertThat(savedUser.getRegistrationDate()).isNotNull();
}
}
這個測試在我電腦上的一個空 Spring 項目上運行大約需要 4.5 秒。
但是一個好的單元測試只需要幾毫秒。否則它會阻礙由測試驅動開發(TDD)思想推動的“測試/代碼/測試”流程。但即使我們不采用 TDD,等待太長時間的測試也會破壞我們的注意力。
執行上面的測試方法實際上只需要幾毫秒。 剩下的 4.5 秒是由於 @SpringBootRun
告訴 Spring Boot 設置整個 Spring Boot 應用程序上下文。
所以我們啟動了整個應用程序只是為了將 RegisterUseCase
實例自動裝配到我們的測試中。一旦應用程序變大並且 Spring 不得不將越來越多的 bean 加載到應用程序上下文中,它將花費更長的時間。
那么,為什么我們不應該在單元測試中使用 Spring Boot 呢?老實說,本教程的大部分內容都是關於在沒有 Spring Boot 的情況下編寫單元測試。
創建可測試的 Spring Bean
然而,我們可以做一些事情來提高 Spring bean 的可測試性。
字段注入是不可取的
讓我們從一個不好的例子開始。考慮以下類:
@Service
public class RegisterUseCase {
@Autowired
private UserRepository userRepository;
public User registerUser(User user) {
return userRepository.save(user);
}
}
這個類不能在沒有 Spring 的情況下進行單元測試,因為它沒有提供傳遞 UserRepository
實例的方法。那么,我們需要按照上一節中討論的方式編寫測試,讓 Spring 創建一個 UserRepository
實例並將其注入到用 @Autowired
注解的字段中。
這里的教訓是不要使用字段注入。
提供構造函數
實際上,我們根本不要使用 @Autowired
注解:
@Service
public class RegisterUseCase {
private final UserRepository userRepository;
public RegisterUseCase(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User registerUser(User user) {
return userRepository.save(user);
}
}
這個版本通過提供允許傳入 UserRepository
實例的構造函數來允許構造函數注入。在單元測試中,我們現在可以創建這樣一個實例(可能是我們稍后討論的模擬實例)並將其傳遞給構造函數。
在創建生產應用程序上下文時,Spring 將自動使用此構造函數來實例化 RegisterUseCase
對象。注意,在 Spring 5 之前,我們需要在構造函數中添加 @Autowired
注解,以便 Spring 找到構造函數。
還要注意 UserRepository
字段現在是 final
。這是有道理的,因為字段內容在應用程序的生命周期內永遠不會改變。它還有助於避免編程錯誤,因為如果我們忘記初始化字段,編譯器會報錯。
減少樣板代碼
使用 Lombok 的 @RequiredArgsConstructor
注解,我們可以讓構造函數自動生成:
@Service
@RequiredArgsConstructor
public class RegisterUseCase {
private final UserRepository userRepository;
public User registerUser(User user) {
user.setRegistrationDate(LocalDateTime.now());
return userRepository.save(user);
}
}
現在,我們有一個非常簡潔的類,沒有樣板代碼,可以在普通的 java 測試用例中輕松實例化:
class RegisterUseCaseTest {
private UserRepository userRepository = ...;
private RegisterUseCase registerUseCase;
@BeforeEach
void initUseCase() {
registerUseCase = new RegisterUseCase(userRepository);
}
@Test
void savedUserHasRegistrationDate() {
User user = new User("zaphod", "zaphod@mail.com");
User savedUser = registerUseCase.registerUser(user);
assertThat(savedUser.getRegistrationDate()).isNotNull();
}
}
然而,還缺少一點,那就是如何模擬我們被測類所依賴的 UserRepository
實例,因為我們不想依賴真實的東西,它可能需要連接到數據庫。
使用 Mockito 來模擬依賴
現在事實上的標准模擬庫是 Mockito。它至少提供了兩種方法來創建模擬的 UserRepository
以填補前面代碼示例中的空白。
使用普通 Mockito 模擬依賴項
第一種方法是以編程方式使用 Mockito:
private UserRepository userRepository = Mockito.mock(UserRepository.class);
這將創建一個從外部看起來像 UserRepository
的對象。默認情況下,當一個方法被調用時它什么都不做,如果該方法有返回值則返回 null
。
我們的測試現在將在 assertThat(savedUser.getRegistrationDate()).isNotNull()
處以 NullPointerException
失敗,因為 userRepository.save(user)
現在返回 null
。
所以,我們必須告訴 Mockito 在調用 userRepository.save()
時返回一些東西。我們使用靜態 when
方法來做到這一點:
@Test
void savedUserHasRegistrationDate() {
User user = new User("zaphod", "zaphod@mail.com");
when(userRepository.save(any(User.class))).then(returnsFirstArg());
User savedUser = registerUseCase.registerUser(user);
assertThat(savedUser.getRegistrationDate()).isNotNull();
}
這將使 userRepository.save()
返回傳遞給方法的相同用戶對象。
Mockito 具有更多功能,可以進行模擬、匹配參數和驗證方法調用。有關更多信息,請查看參考文檔。
使用 Mockito 的 @Mock
注解模擬依賴項
創建模擬對象的另一種方法是 Mockito 的 @Mock
注解與 JUnit Jupiter 的 MockitoExtension
相結合:
@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {
@Mock
private UserRepository userRepository;
private RegisterUseCase registerUseCase;
@BeforeEach
void initUseCase() {
registerUseCase = new RegisterUseCase(userRepository);
}
@Test
void savedUserHasRegistrationDate() {
// ...
}
}
@Mock
注解指定了 Mockito 應該注入模擬對象的字段。 @MockitoExtension
告訴 Mockito 評估那些 @Mock
注解,因為 JUnit 不會自動執行此操作。
結果和手動調用 Mockito.mock()
一樣,選擇使用哪種方式是品味問題。 但是請注意,通過使用 MockitoExtension
將我們的測試綁定到測試框架。
請注意,我們也可以在 registerUseCase
字段上使用 @InjectMocks
注解,而不是手動構造 RegisterUseCase
對象。然后 Mockito 會按照指定的算法為我們創建一個實例:
@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private RegisterUseCase registerUseCase;
@Test
void savedUserHasRegistrationDate() {
// ...
}
}
使用 AssertJ 創建可讀斷言
Spring Boot 測試支持自動附帶的另一個庫是 AssertJ。我們已經在上面使用它來實現我們的斷言:
assertThat(savedUser.getRegistrationDate()).isNotNull();
然而,讓斷言更具可讀性不是更好嗎?例如:
assertThat(savedUser).hasRegistrationDate();
在很多情況下,像這樣的小改動會使測試更容易理解。因此,讓我們在測試源文件夾中創建我們自己的自定義斷言:
class UserAssert extends AbstractAssert<UserAssert, User> {
UserAssert(User user) {
super(user, UserAssert.class);
}
static UserAssert assertThat(User actual) {
return new UserAssert(actual);
}
UserAssert hasRegistrationDate() {
isNotNull();
if (actual.getRegistrationDate() == null) {
failWithMessage(
"Expected user to have a registration date, but it was null"
);
}
return this;
}
}
現在,如果我們從新的 UserAssert
類而不是從 AssertJ 庫導入 assertThat
方法,我們就可以使用新的、更易於閱讀的斷言。
創建像這樣的自定義斷言似乎需要很多工作,但實際上只需幾分鍾即可完成。我堅信投入這些時間來創建可讀的測試代碼是值得的,即使之后它的可讀性只是稍微好一點。畢竟,我們只編寫一次測試代碼,其他人(包括“未來的我”)必須在軟件的生命周期中多次閱讀、理解和操作代碼。
如果仍然覺得工作量太大,請查看 AssertJ 的斷言生成器。
結論
在測試中啟動 Spring 應用程序是有原因的,但對於普通的單元測試來說,這是沒有必要的。由於更長的周轉時間,它甚至是有害的。相反,我們應該以一種易於支持為其編寫簡單單元測試的方式構建我們的 Spring bean。
Spring Boot Test Starter 附帶 Mockito 和 AssertJ 作為測試庫。
讓我們利用這些測試庫來創建富有表現力的單元測試!
最終形式的代碼示例可在 github 上 找到。