使用 Spring Boot 和 @WebMvcTest 測試 MVC Web Controller

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

【注】本文譯自: Testing MVC Web Controllers with Spring Boot and @WebMvcTest - Reflectoring

在有關使用 Spring Boot 進行測試的系列的第二部分中,我們將瞭解 Web 控制器。首先,我們將探索 Web 控制器的實際作用,這樣我們就可以構建涵蓋其所有職責的測試。
然後,我們將找出如何在測試中涵蓋這些職責。只有涵蓋了這些職責,我們才能確保我們的控制器在生產環境中按預期執行。

 程式碼示例

本文附有 GitHub 上的工作程式碼示例。

依賴

我們將使用 JUnit Jupiter (JUnit 5) 作為測試框架,使用 Mockito 進行模擬,使用 AssertJ 來建立斷言,使用 Lombok 來減少樣板程式碼:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    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')
}

AssertJ 和 Mockito 跟隨 spring-boot-starter-test 依賴自動獲得。

Web 控制器的職責

讓我們從一個典型的 REST 控制器開始:

@RestController
@RequiredArgsConstructor
class RegisterRestController {
    private final RegisterUseCase registerUseCase;

    @PostMapping("/forums/{forumId}/register")
    UserResource register(@PathVariable("forumId") Long forumId, @Valid @RequestBody UserResource userResource,
            @RequestParam("sendWelcomeMail") boolean sendWelcomeMail) {

        User user = new User(userResource.getName(), userResource.getEmail());
        Long userId = registerUseCase.registerUser(user, sendWelcomeMail);

        return new UserResource(userId, user.getName(), user.getEmail());
    }

}

控制器方法用 @PostMapping 註解來定義它應該偵聽的 URL、HTTP 方法和內容型別。
它通過用 @PathVariable@RequestBody@RequestParam 註解的引數獲取輸入,這些引數會從傳入的 HTTP 請求中自動填充。
引數可以使用 @Valid 進行註解,以指示 Spring 應該對它們 bean 驗證
然後控制器使用這些引數,呼叫業務邏輯返回一個普通的 Java 物件,預設情況下該物件會自動對映到 JSON 並寫入 HTTP 響應體。
這裡有很多 spring 魔法。總之,對於每個請求,控制器通常會執行以下步驟:

#職責描述
1.監聽 HTTP 請求控制器應該響應某些 URL、HTTP 方法和內容型別。
2.反序列化輸入控制器應該解析傳入的 HTTP 請求並根據 URL、HTTP 請求引數和請求正文中的變數建立 Java 物件,以便我們可以在程式碼中使用它們。
3.驗證輸入控制器是防止錯誤輸入的第一道防線,因此它是我們可以驗證輸入的地方。
4.呼叫業務邏輯解析輸入後,控制器必須將輸入轉換為業務邏輯期望的模型並將其傳遞給業務邏輯。
5.序列化輸出控制器獲取業務邏輯的輸出並將其序列化為 HTTP 響應。
6.轉換異常如果在某個地方發生異常,控制器應將其轉換為對使用者有意義的錯誤訊息和 HTTP 狀態。

控制器顯然有很多工作要做!
我們應該注意不要新增更多的職責,比如執行業務邏輯。否則,我們的控制器測試將變得臃腫且無法維護。
我們將如何編寫有意義的測試,涵蓋所有這些職責?

單元測試還是整合測試?

我們寫單元測試嗎?還是整合測試?到底有什麼區別?讓我們討論這兩種方法並決定其中一種。
在單元測試中,我們將單獨測試控制器。這意味著我們將例項化一個控制器物件,模擬業務邏輯,然後呼叫控制器的方法並驗證響應。
這對我們有用嗎?讓我們檢查一下可以單獨的單元測試中涵蓋上面確定的 6 個職責中的哪一個:

#職責可以在單元測試中涵蓋嗎
1.監聽 HTTP 請求❌ 不,因為單元測試不會評估 @PostMapping 註解和指定 HTTP 請求屬性的類似註解。
2.反序列化輸入❌ 不,因為像@RequestParam 和 @PathVariable 這樣的註釋不會被評估。相反,我們將輸入作為 Java 物件提供,從而有效地跳過 HTTP 請求的反序列化。
3.驗證輸入❌ 不依賴於 bean 驗證,因為不會評估 @Valid 註釋。
4.呼叫業務邏輯✔ 是的,因為我們可以驗證是否使用預期的引數呼叫了模擬的業務邏輯。
5.序列化輸出❌ 不能,因為我們只能驗證輸出的 Java 版本,而不能驗證將生成的 HTTP 響應。
6.轉換異常❌ 不可以。我們可以檢查是否引發了某個異常,但不能檢查它是否被轉換為某個 JSON 響應或 HTTP 狀態程式碼。

與 Spring 的整合測試會啟動一個包含我們需要的所有 bean 的 Spring 應用程式上下文。這包括負責偵聽某些 URL、與 JSON 之間進行序列化和反序列化以及將異常轉換為 HTTP 的框架 bean。這些 bean 將評估簡單單元測試會忽略的註釋。總之,簡單的單元測試不會覆蓋 HTTP 層。所以,我們需要在我們的測試中引入 Spring 來為我們做 HTTP 魔法。因此,我們正在構建一個整合測試來測試我們的控制器程式碼和 Spring 為 HTTP 支援提供的元件之間的整合。
那麼,我們該怎麼做呢?

使用 @WebMvcTest 驗證控制器職責

Spring Boot 提供了 @WebMvcTest 註釋來啟動一個應用程式上下文,該上下文只包含測試 Web 控制器所需的 bean:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = RegisterRestController.class)
class RegisterRestControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private RegisterUseCase registerUseCase;

  @Test
  void whenValidInput_thenReturns200() throws Exception {
    mockMvc.perform(...);
  }
}
@ExtendWith
本教程中的程式碼示例使用 @ExtendWith 批註告訴 JUnit 5 啟用 Spring 支援。從 Spring Boot 2.1 開始,我們不再需要載入 SpringExtension,因為它作為元註釋包含在 Spring Boot 測試註解中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

我們現在可以 @Autowire 從應用程式上下文中獲取我們需要的所有 bean。Spring Boot 自動提供了像 ObjectMapper 這樣的 bean 來對映到 JSON 和一個 MockMvc 例項來模擬 HTTP 請求。
我們使用 @MockBean 來模擬業務邏輯,因為我們不想測試控制器和業務邏輯之間的整合,而是控制器和 HTTP 層之間的整合。@MockBean 自動用 Mockito 模擬替換應用程式上下文中相同型別的 bean。
您可以在我關於模擬的文章中閱讀有關 @MockBean 註解的更多資訊。

使用帶或不帶 controllers 引數的 @WebMvcTest
通過在上面的示例中將 controllers 引數設定為 RegisterRestController.class,我們告訴 Spring Boot 將為此測試建立的應用程式上下文限制為給定的控制器 bean 和 Spring Web MVC 所需的一些框架 bean。我們可能需要的所有其他 bean 必須單獨包含或使用 @MockBean 模擬。
如果我們不使用 controllers 引數,Spring Boot 將在應用程式上下文中包含所有控制器。因此,我們需要包含或模擬掉任何控制器所依賴的所有 bean。這使得測試設定更加複雜,具有更多的依賴項,但節省了執行時間,因為所有控制器測試都將重用相同的應用程式上下文。
我傾向於將控制器測試限制在最窄的應用程式上下文中,以使測試獨立於我在測試中甚至不需要的 bean,即使 Spring Boot 必須為每個單獨的測試建立一個新的應用程式上下文。

讓我們來回顧一下每個職責,看看我們如何使用 MockMvc 來驗證每一個職責,以便構建我們力所能及的最好的整合測試。

1. 驗證 HTTP 請求匹配

驗證控制器是否偵聽某個 HTTP 請求非常簡單。我們只需呼叫 MockMvcperform() 方法並提供我們要測試的 URL:

mockMvc.perform(post("/forums/42/register")
    .contentType("application/json"))
    .andExpect(status().isOk());

除了驗證控制器對特定 URL 的響應之外,此測試還驗證正確的 HTTP 方法(在我們的示例中為 POST)和正確的請求內容型別。我們上面看到的控制器會拒絕任何具有不同 HTTP 方法或內容型別的請求。
請注意,此測試仍然會失敗,因為我們的控制器需要一些輸入引數。
更多匹配 HTTP 請求的選項可以在 MockHttpServletRequestBuilder 的 Javadoc 中找到。

2. 驗證輸入序列化

為了驗證輸入是否成功序列化為 Java 物件,我們必須在測試請求中提供它。輸入可以是請求正文的 JSON 內容 (@RequestBody)、URL 路徑中的變數 (@PathVariable) 或 HTTP 請求引數 (@RequestParam):

@Test
void whenValidInput_thenReturns200() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");
 
   mockMvc.perform(post("/forums/{forumId}/register", 42L)
        .contentType("application/json")
        .param("sendWelcomeMail", "true")
        .content(objectMapper.writeValueAsString(user)))
        .andExpect(status().isOk());
}

我們現在提供路徑變數 forumId、請求引數 sendWelcomeMail 和控制器期望的請求正文。請求正文是使用 Spring Boot 提供的 ObjectMapper 生成的,將 UserResource 物件序列化為 JSON 字串。
如果測試結果為綠色,我們現在知道控制器的 register() 方法已將這些引數作為 Java 物件接收,並且它們已從 HTTP 請求中成功解析。

3. 驗證輸入驗證

假設 UserResource 使用 @NotNull 註釋來拒絕 null 值:

@Value
public class UserResource {

    @NotNull
    private final String name;

    @NotNull
    private final String email;

}

當我們@Valid 註解新增到方法引數時,Bean 驗證會自動觸發,就像我們在控制器中使用 userResource 引數所做的那樣。因此,對於快樂路徑(即驗證成功時),我們在上一節中建立的測試就足夠了。
如果我們想測試驗證是否按預期失敗,我們需要新增一個測試用例,在該用例中我們將無效的 UserResource JSON 物件傳送到控制器。然後我們期望控制器返回 HTTP 狀態 400(錯誤請求):

@Test
void whenNullValue_thenReturns400() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");
 
  mockMvc.perform(post("/forums/{forumId}/register", 42L)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest());
}

根據驗證對應用程式的重要性,我們可能會為每個可能的無效值新增這樣的測試用例。但是,這會很快增加很多測試用例,因此您應該與您的團隊討論您希望如何處理專案中的驗證測試。

4. 驗證業務邏輯呼叫

接下來,我們要驗證業務邏輯是否按預期呼叫。在我們的例子中,業務邏輯由 RegisterUseCase 介面提供,並需要一個 User 物件和一個 boolean 值作為輸入:

interface RegisterUseCase {
    Long registerUser(User user, boolean sendWelcomeMail);
}

我們希望控制器將傳入的 UserResource 物件轉換為 User 並將此物件傳遞給 registerUser() 方法。
為了驗證這一點,我們可以要求 RegisterUseCase 模擬,它已使用 @MockBean 註解注入到應用程式上下文中:

@Test
void whenValidInput_thenMapsToBusinessModel() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");
  mockMvc.perform(...);

  ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
  verify(registerUseCase, times(1)).registerUser(userCaptor.capture(), eq(true));
  assertThat(userCaptor.getValue().getName()).isEqualTo("Zaphod");
  assertThat(userCaptor.getValue().getEmail()).isEqualTo("zaphod@galaxy.net");
}

在執行了對控制器的呼叫之後,我們使用 ArgumentCaptor 來捕獲傳遞給 RegisterUseCase.registerUser()User 物件並斷言它包含預期值。
呼叫 verify 檢查 registerUser() 是否被呼叫過一次。
請注意,如果我們對 User 物件進行大量斷言,我們可以 建立自己的自定義 Mockito 斷言方法 以獲得更好的可讀性。

5. 驗證輸出序列化

呼叫業務邏輯後,我們希望控制器將結果對映到 JSON 字串並將其包含在 HTTP 響應中。在我們的例子中,我們希望 HTTP 響應正文包含一個有效的 JSON 格式的 UserResource 物件:

@Test
void whenValidInput_thenReturnsUserResource() throws Exception {
  MvcResult mvcResult = mockMvc.perform(...)
      ...
      .andReturn();

  UserResource expectedResponseBody = ...;
  String actualResponseBody = mvcResult.getResponse().getContentAsString();
 
  assertThat(actualResponseBody).isEqualToIgnoringWhitespace(
              objectMapper.writeValueAsString(expectedResponseBody));
}

要對響應主體進行斷言,我們需要使用 andReturn() 方法將 HTTP 互動的結果儲存在 MvcResult 型別的變數中。
然後我們可以從響應正文中讀取 JSON 字串,並使用 isEqualToIgnoringWhitespace() 將其與預期的字串進行比較。我們可以使用 Spring Boot 提供的 ObjectMapper 從 Java 物件構建預期的 JSON 字串。
請注意,我們可以通過使用自定義的 ResultMatcher 使其更具可讀性,稍後對此加以描述

6. 驗證異常處理

通常,如果發生異常,控制器應該返回某個 HTTP 狀態。400 --- 如果請求有問題,500 --- 如果出現異常,等等。
預設情況下,Spring 會處理大多數這些情況。但是,如果我們有自定義異常處理,我們想測試它。假設我們想要返回一個結構化的 JSON 錯誤響應,其中包含請求中每個無效欄位的欄位名稱和錯誤訊息。我們會像這樣建立一個 @ControllerAdvice

@ControllerAdvice
class ControllerExceptionHandler {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    ErrorResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        ErrorResult errorResult = new ErrorResult();
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
            errorResult.getFieldErrors()
                    .add(new FieldValidationError(fieldError.getField(), fieldError.getDefaultMessage()));
        }
        return errorResult;
    }

    @Getter
    @NoArgsConstructor
    static class ErrorResult {
        private final List<FieldValidationError> fieldErrors = new ArrayList<>();

        ErrorResult(String field, String message) {
            this.fieldErrors.add(new FieldValidationError(field, message));
        }
    }

    @Getter
    @AllArgsConstructor
    static class FieldValidationError {
        private String field;
        private String message;
    }
}

如果 bean 驗證失敗,Spring 將丟擲 MethodArgumentNotValidException。我們通過將 Spring 的 FieldError 物件對映到我們自己的 ErrorResult 資料結構來處理這個異常。在這種情況下,異常處理程式會導致所有控制器返回 HTTP 狀態 400,並將 ErrorResult 物件作為 JSON 字串放入響應正文中。
為了驗證這確實發生了,我們擴充套件了我們之前對失敗驗證的測試:

@Test
void whenNullValue_thenReturns400AndErrorResult() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  MvcResult mvcResult = mockMvc.perform(...)
          .contentType("application/json")
          .param("sendWelcomeMail", "true")
          .content(objectMapper.writeValueAsString(user)))
          .andExpect(status().isBadRequest())
          .andReturn();

  ErrorResult expectedErrorResponse = new ErrorResult("name", "must not be null");
  String actualResponseBody =
      mvcResult.getResponse().getContentAsString();
  String expectedResponseBody =
      objectMapper.writeValueAsString(expectedErrorResponse);
  assertThat(actualResponseBody)
      .isEqualToIgnoringWhitespace(expectedResponseBody);
}

同樣,我們從響應正文中讀取 JSON 字串,並將其與預期的 JSON 字串進行比較。此外,我們檢查響應狀態是否為 400。
這也可以以可讀性更強的方式實現,我們接下來將要學習。建立自定義 ResultMatcher
某些斷言很難寫,更重要的是,很難閱讀。特別是當我們想要將來自 HTTP 響應的 JSON 字串與預期值進行比較時,它需要大量程式碼,正如我們在最後兩個示例中看到的那樣。
幸運的是,我們可以建立自定義的 ResultMatcher,我們可以在 MockMvc 的流暢 API 中使用它們。讓我們看看如何做到這一點。匹配 JSON 輸出
使用以下程式碼來驗證 HTTP 響應正文是否包含某個 Java 物件的 JSON 表示不是很好嗎?

@Test
void whenValidInput_thenReturnsUserResource_withFluentApi() throws Exception {
  UserResource user = ...;
  UserResource expected = ...;

  mockMvc.perform(...)
      ...
      .andExpect(responseBody().containsObjectAsJson(expected, UserResource.class));
}

不再需要手動比較 JSON 字串。它的可讀性要好得多。事實上,程式碼是如此的一目瞭然,這裡我無需解釋。
為了能夠使用上面的程式碼,我們建立了一個自定義的 ResultMatcher

public class ResponseBodyMatchers {
    private ObjectMapper objectMapper = new ObjectMapper();

    public <T> ResultMatcher containsObjectAsJson(Object expectedObject, Class<T> targetClass) {
        return mvcResult -> {
            String json = mvcResult.getResponse().getContentAsString();
            T actualObject = objectMapper.readValue(json, targetClass);
            assertThat(actualObject).isEqualToComparingFieldByField(expectedObject);
        };
    }

    static ResponseBodyMatchers responseBody() {
        return new ResponseBodyMatchers();
    }

}

靜態方法 responseBody() 用作我們流暢的 API 的入口點。它返回實際的 ResultMatcher,它從 HTTP 響應正文解析 JSON,並將其與傳入的預期物件逐個欄位進行比較。匹配預期的驗證錯誤
我們甚至可以更進一步簡化我們的異常處理測試。我們用了 4 行程式碼來驗證 JSON 響應是否包含某個錯誤訊息。我們可以改為一行:

@Test
void whenNullValue_thenReturns400AndErrorResult_withFluentApi() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  mockMvc.perform(...)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest())
      .andExpect(responseBody().containsError("name", "must not be null"));
}

同樣,程式碼是自解釋的。
為了啟用這個流暢的 API,我們必須從上面新增方法 containsErrorMessageForField() 到我們的 ResponseBodyMatchers 類:

public class ResponseBodyMatchers {
    private ObjectMapper objectMapper = new ObjectMapper();

    public ResultMatcher containsError(String expectedFieldName, String expectedMessage) {
        return mvcResult -> {
            String json = mvcResult.getResponse().getContentAsString();
            ErrorResult errorResult = objectMapper.readValue(json, ErrorResult.class);
            List<FieldValidationError> fieldErrors = errorResult.getFieldErrors().stream()
                    .filter(fieldError -> fieldError.getField().equals(expectedFieldName))
                    .filter(fieldError -> fieldError.getMessage().equals(expectedMessage)).collect(Collectors.toList());

            assertThat(fieldErrors).hasSize(1).withFailMessage(
                    "expecting exactly 1 error message" + "with field name '%s' and message '%s'", expectedFieldName,
                    expectedMessage);
        };
    }

    static ResponseBodyMatchers responseBody() {
        return new ResponseBodyMatchers();
    }
}

所有醜陋的程式碼都隱藏在這個輔助類中,我們可以在整合測試中愉快地編寫乾淨的斷言。

結論

Web 控制器有很多職責。如果我們想用有意義的測試覆蓋一個 web 控制器,僅僅檢查它是否返回正確的 HTTP 狀態是不夠的。
通過 @WebMvcTest,Spring Boot 提供了我們構建 Web 控制器測試所需的一切,但為了使測試有意義,我們需要記住涵蓋所有職責。否則,我們可能會在執行時遇到醜陋的驚喜。
本文中的示例程式碼可在 GitHub 上找到。

相關文章