1. 引言
Spring 的測試生態系統已經從基於模擬的 Mock 模擬演變為與嵌入式服務器的完整集成。最新增加入 RestTestClient (Spring Framework 7.0),通過提供簡潔的 Builder 風格的 HTTP 交互界面,而無需傳統客户端的繁瑣,從而彌合了這一差距。這使其成為 MockMvc 或 WebTestClient 的輕量級替代方案,非常適合需要速度、可讀性和靈活性的一體化測試。
在本教程中,我們將 RestTestClient 設置到 Spring Boot 項目中,探索涵蓋基本請求、錯誤處理、斷言等實用示例,並強調最佳實踐,以確保我們的測試具有魯棒性和可維護性。
2. 設置 RestTestClient
要使用 RestTestClient,我們需要一個帶有適當測試依賴的 Spring Boot 項目。
首先,我們將 Spring Boot Test starter 添加到我們的 pom.xml 中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>我們還需要確保我們使用的是 Spring Framework 7.0 或更高版本,因為 RestTestClient 是一個較新的添加項。
接下來,讓我們配置一個帶有 @SpringBootTest 註解的測試類,以加載應用程序上下文:
@SpringBootTest
class RestTestClientUnitTest {
private RestTestClient restTestClient;
@BeforeEach
void beforeEach() {
// TODO: initialize restTestClient
}
}此配置允許我們以任何方式實例化 RestTestClient,在 beforeEach() 方法中。
2.1. 綁定
在編寫測試之前,我們必須決定如何創建我們的 RestTestClient。 其優勢之一在於多種綁定的選項:
- 綁定到已初始化好的 MockMvc 實例,用作服務器: bindTo(MockMvc mockMvc)
- 綁定到實時服務器: bindToServer(ClientHttpRequestFactory requestFactory)
- 綁定到 WebApplicationContext: bindToApplicationContext(WebApplicationContext context)
- 綁定到 (多個) RouterFunction: bindToRouterFunction(RouterFunction<?>… routerFunctions)
- 綁定到 (多個) Controllers: bindToController(Object… controllers)
這些選項提供了極高的靈活性,使其成為測試任何 Spring Boot 項目的理想選擇。
2.2. 配置
測試前的最後一步是客户端配置。我們可以通過其構建器對 RestTestClient 進行微調:
restTestClientBuilder
.baseUrl("/public") // 1
.defaultHeader("ContentType", "application/json") // 2
.defaultCookie("JSESSIONID", "abc123def456ghi789") // 3
.build();以下三個選項在示例中:
- 設置基本 URL,例如前綴 /public
- 設置默認標頭,例如內容類型
- 設置默認 Cookie,例如會話 ID
完成設置後,我們準備好進行第一次測試。
3. 實際示例
讓我們探索幾個場景,以突出顯示 RestTestClient’ 的靈活性,包括不同類型的用例和複雜的斷言。
以下測試將綁定到並針對我們的控制器運行:
@RestController("my")
class MyController {
@GetMapping("/persons/{id}")
public ResponseEntity<Person> getPersonById(@PathVariable Long id) {
return id == 1
? ResponseEntity.ok(new Person(1L, "John Doe"))
: ResponseEntity.noContent().build();
}
}getPersonById() 方法返回的實體是 Person:
record Person(Long id, String name) { }3.1. 順利路徑
我們的第一個測試將覆蓋順利路徑,通過向特定ID的個人對象發出GET請求來檢索個人信息:
@Test
void givenValidPath_whenCalled_thenReturnOk() {
restTestClient.get() // 1
.uri("/persons/1") // 2
.accept(MediaType.APPLICATION_JSON) // 3
.exchange() // 4
.expectStatus() // 5
.isOk() // 6
.expectBody(Person.class) // 7
.isEqualTo(new Person(1L, "John Doe")); // 8
}我們使用 RestTestClient 的 API 來 (1) 初始化一個合適的請求,指向 (2) 某個路徑,幷包含 (3) 適當的 Accept 頭部。然後我們 (4) 執行該請求,並 (5) 檢查其預期狀態——在我們的例子中是 OK (6)。最後,我們 (7) 將響應體轉換為返回類型 Person,並 (8) 驗證其與預期實例的匹配。
這看起來相當簡單,但我們如何檢查不成功的請求呢?
3.2. 簡單錯誤情況
我們的第二個測試涵蓋了一個客户端錯誤(錯誤的 HTTP 方法):
@Test
void givenWrongMethod_whenCalled_thenReturnClientError() {
restTestClient.post() // <== wrong method
.uri("/persons/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.is4xxClientError();
}以下是如何檢查 NO_CONTENT 響應(無效 ID):
@Test
void givenWrongId_whenCalled_thenReturnNoContent() {
restTestClient.get()
.uri("/persons/0") // <== wrong id
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.isNoContent();
}3.3. JSON 斷言
<em/>RestTestClient</em/> 與 JSON Path 無縫集成,可用於詳細的請求體斷言:
@Test
void givenValidId_whenGetPerson_thenReturnsCorrectFields() {
restTestClient.get()
.uri("/persons/1")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.name").isEqualTo("John Doe");
}這避免了完整的對象反序列化,同時精確驗證了結構和值。
3.4. 自定義斷言
如果對於更復雜的場景,我們更傾向於使用自定義斷言,可以使用 consumeWith() 方法:
@Test
void givenValidRequest_whenGetPerson_thenPassesAllAssertions() {
restTestClient.get()
.uri("/persons/1")
.exchange()
.expectStatus()
.isOk()
.expectBody(Person.class)
.consumeWith(result -> {
assertThat(result.getStatus().value()).isEqualTo(200);
assertThat(result.getResponseBody().name()).isNotNull().isEqualTo("John Doe");
});
}在我們提供的 Consumer<EntityExchangeResult<B>> 中,我們可以使用任何斷言,例如 AssertJ 庫。
3.5. 多個控制器
以下場景我們將探討如何同時使用多個控制器。
RestTestClient 使得可以綁定多個控制器:
restTestClient = RestTestClient.bindToController(myController, anotherController)
.build();現在我們可以編寫一個測試,斷言第二個控制器的端點,在同一個測試類中:
@Test
void givenValidQueryToSecondController_whenGetPenguinMono_thenReturnsEmpty() {
restTestClient.get()
.uri("/pink/penguin")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.isOk()
.expectBody(Penguin.class)
.value(it -> assertThat(it).isNull());
}這種方法對於測試複合 API 或模塊化服務非常有用,可以確保控制器之間的交互按照預期工作,而無需啓動整個服務器。
4. 最佳實踐與潛在問題
由於我們已經涵蓋了基本內容,現在我們將深入探討 RestTestClient 的擴展可能性和潛在問題。
4.1. 選擇錯誤的綁定設置
在使用 <em >RestTestClient</em> 時,首先要確定的就是應該使用哪種綁定模式。正如之前提到的,API 支持廣泛的綁定模式。很容易選擇錯誤的模式。
一個常見的陷阱是在我們真正需要完整服務器行為時,使用“mock”綁定(控制器或上下文)。例如,如果我們綁定到控制器,我們可能不會測試完整的 HTTP 棧(servlet 過濾器、Spring Security、全局註冊的消息轉換器)!
錯誤的綁定會導致測試中的假陰性(測試通過,但在生產環境中會失敗)。我們應該確保,綁定的方法和提供的參數對於預期用例有效。
4.2. 線程安全與上下文
<em>RestTestClient</em> 實例在構建完成後即不可變,因此具有線程安全特性,並適合於並行測試執行。這種不可變性確保了沒有共享可變狀態,允許在不產生競爭條件的情況下在測試之間安全地重複使用,這對於加速大型測試套件非常理想。
<em>RestTestClient.Builder</em> 儘管如此,仍然是可變的,並且不可線程安全。在測試或線程之間共享單個構建器實例可能會導致不可預測的配置,例如覆蓋的標頭。
我們應該為每個測試創建一個新的構建器,或者僅使用構建好的不可變實例(推薦)。
4.3. 未注意的行為差異
使用 RestTestClient 存在一個微妙的風險,即與諸如 WebTestClient 這樣其他測試客户端相比。例如,存在一個 公開問題,其中 RestTestClient 在控制器未返回任何主體時,會返回 null 值,而 WebTestClient 則返回一個空 byte[]。
這意味着,如果我們編寫斷言以期望一個空主體,並且在客户端和真實服務器之間切換上下文,我們可能會遇到 NPE 或誤導性結果。
最佳實踐:我們應該明確斷言 expectBody().isEmpty() 以確保在沒有主體預期時,而不是依賴 returnResult() 然後檢查 null 與數組。
5. 結論
在本文中,我們探討了 RestTestClient,這是一種現代、簡潔的補充,適用於 Spring Framework 7.0,它簡化了 Spring Boot 中的 REST 集成測試。
該客户端提供靈活的綁定和配置,以及對 JSON、標頭和 Cookie 的表達性斷言,在可讀性和功能性之間取得了平衡,使其成為比其他更重的替代方案更好的選擇。
如往常一樣,代碼可以在 GitHub 上找到。