使用 Spring Boot 進行單元測試

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