使用 Spring Boot 進行單元測試

信碼由韁發表於2021-11-01

【注】本文譯自: 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 上 找到。

相關文章