使用 REST-assured 斷言 REST JSON 回應
1. 概述
當我們測試傳回 JSON 的 HTTP 端點時,我們希望能夠檢查回應正文的內容。通常我們希望捕獲此 JSON 的範例並將其儲存在格式化的範例檔案中以與回應進行比較。
但是,如果傳回的 JSON 中的某些欄位的順序與我們的範例不同,或者某些欄位包含從一個回應到下一個回應的值,我們可能會遇到問題。
我們可以使用 REST-assured 來編寫我們的測試斷言,但它在預設情況下並不能解決上述所有問題。在本教程中,我們將了解如何使用 REST-assured 來斷言 JSON 主體,以及如何使用 JSONAssert、 JsonUnit和 ModelAssert 來更輕鬆地處理變更的字段,或與精確回應格式不同的預期 JSON。
2. 專案設定範例
我們可以使用 REST-assured 來測試任何類型的 HTTP 伺服器。它通常與 Spring Boot 和 Micronaut 測試一起使用。
對於我們的範例,讓我們使用 WireMock 來模擬我們正在測試的伺服器。
2.1.設定 WireMock
讓我們將WireMock的依賴項加入pom.xml
中:
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.9.1</version>
<scope>test</scope>
</dependency>
現在我們可以建立測試以使用 WireMockTest JUnit 5 擴充:
@WireMockTest
class WireMockTest {
@BeforeEach
void beforeEach(WireMockRuntimeInfo wmRuntimeInfo) {
// set up wiremock
}
}
2.2.新增範例端點
在beforeEach()
方法中,我們告訴 WireMock 模擬一個端點,該端點在每次請求/static
時傳回一致的靜態資料:
stubFor(get("/static").willReturn(
aResponse()
.withStatus(200)
.withHeader("content-type", "application/json")
.withBody("{\"name\":\"baeldung\",\"type\":\"website\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}")));
然後我們添加一個/build
端點,它還添加一些隨每個請求而變化的運行時資料:
stubFor(get("/build").willReturn(
aResponse()
.withStatus(200)
.withHeader("content-type", "application/json")
.withBody("{\"build\":\"" +
UUID.randomUUID() +
"\",\"timestamp\":\"" +
LocalDateTime.now() +
"\",\"name\":\"baeldung\",\"type\":\"website\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}")));
這裡我們的build
和timestamp
欄位分別是 UUID 和日期戳記。
2.3.捕獲 JSON 正文
此時捕獲端點的實際輸出並將其放入 JSON 檔案中以用作預期響應是很常見的。
這是我們的靜態端點輸出:
{
"name": "baeldung",
"type": "website",
"text": {
"language": "english",
"code": "java"
}
}
這是/build
端點的輸出:
{
"build": "360dac90-38bc-4430-bbc3-a46091aea135",
"timestamp": "2024-09-09T22:33:46.691667",
"name": "baeldung",
"type": "website",
"text": {
"language": "english",
"code": "java"
}
}
2.4.設定 REST-assured
讓我們將REST-assured加入到pom.xml:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
我們可以將 REST-assured 用戶端配置為使用 WireMock 在測試類別的beforeAll()
中公開的連接埠:
@BeforeEach
void beforeEach(WireMockRuntimeInfo wmRuntimeInfo) {
RestAssured.port = wmRuntimeInfo.getHttpPort();
}
現在我們準備寫一些斷言。
3. 使用開箱即用的 REST-assured
REST-assured 提供給given()
/ then()
結構來設定和斷言 HTTP 請求。這包括在回應標頭、狀態代碼或正文中斷言預期值的能力。它還可以讓我們提取正文以進行更深入的斷言。
讓我們先了解如何使用 REST-assured 的內建功能檢查 JSON 回應。
3.1.透過 REST-assured 斷言各個字段
按照慣例,我們可以使用 REST-assured body()
方法來斷言回應中單一欄位的值:
given()
.get("/static")
.then()
.body("name", equalTo("baeldung"));
它使用 JSON 路徑表達式作為body()
的第一個參數,後面跟著 Hamcrest 匹配器來指示預期值。
雖然這對於測試各個欄位來說非常精確,但當需要斷言整個 JSON 物件時,它就會變得冗長:
given()
.get("/static")
.then()
.body("name", equalTo("baeldung"))
.body("type", equalTo("website"))
.body("text.code", equalTo("java"))
.body("text.language", equalTo("english"));
3.2.將整個 JSON 正文斷言為字串
REST-assured 讓我們可以提取整個主體並在 REST-assured 完成檢查後斷言它:
String body = given()
.get("/static")
.then()
.extract()
.body()
.asString();
assertThat(body)
.isEqualTo("{\"name\":\"baeldung\",\"type\":\"website\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}");
在這裡,我們使用了 AssertJ 中的assertThat()
斷言來檢查結果。我們應該注意到, body()
函數可以使用 Hamcrest 匹配器作為其唯一參數來斷言整個主體。我們稍後會研究該選項。
將整個正文斷言為String
的問題在於它很容易受到欄位順序或格式的影響。
3.3.使用 POJO 斷言整個響應
如果服務傳回的域物件已經在我們的程式碼庫中建模,我們可能會發現使用這些域類別進行測試更容易。在我們的範例中,也許我們有一個WebsitePojo
類別:
public class WebsitePojo {
public static class WebsiteText {
private String language;
private String code;
// getters, setters, equals, hashcode and constructors
}
private String name;
private String type;
private WebsiteText text;
// getters, setters, equals, hashcode and constructors
}
有了這些類別,我們可以編寫一個測試,使用 REST-assured 的extract()
方法為我們轉換為 POJO:
WebsitePojo body = given()
.get("/static")
.then()
.extract()
.body()
.as(WebsitePojo.class);
assertThat(body)
.isEqualTo(new WebsitePojo("baeldung", "website", new WebsiteText("english", "java")));
這裡extract()
方法取得主體,解析它,並使用as()
將其轉換為我們的WebsitePojo
型別。我們可以使用 AssertJ 建構一個與預期值進行比較的物件。
4. 使用 JSONAssert 進行斷言
JSONAssert 是歷史最悠久的 JSON 比較工具之一。它允許定制,使我們能夠處理格式之間的微小差異,以及處理不可預測的值。
4.1.將回應主體與字串進行比較
讓我們使用 JSON Assert 的assertEquals()
將回應正文與預期的String
進行比較:
String body = given()
.get("/static")
.then()
.extract()
.body()
.asString();
JSONAssert.assertEquals("{\"name\":\"baeldung\",\"type\":\"website\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}", body, JSONCompareMode.STRICT);
我們可以在這裡使用STRICT
模式,因為/static
端點會傳回完全可預測的結果。
我們應該注意到 JSON Assert 的方法在出錯時會拋出JSONException
,因此我們的測試方法需要對其throws
:
@Test
void whenGetBody_thenCanCompareByJsonAssertAgainstFile() throws Exception {
}
4.2.將回應正文與文件進行比較
如果我們有一種方便的方法來載入文件,我們可以使用帶有斷言的範例 JSON 文件:
JSONAssert.assertEquals(Files.contentOf(new File("src/test/resources/expected-website.json"), "UTF-8"), body, JSONCompareMode.STRICT);
由於我們有 AssertJ,我們可以使用contentOf()
函數將測試資料檔案作為String
載入。 JSONAssert 會忽略我們的 JSON 檔案已格式化的事實,它會檢查語義等效性,而不是逐個字元。
4.3.將回應與額外欄位進行比較
解決不可預測欄位的一種方法是忽略它們。我們可以將/build
的響應與/static
中找到的值子集進行比較:
JSONAssert.assertEquals(Files.contentOf(new File("src/test/resources/expected-website.json"), "UTF-8"), body, JSONCompareMode.LENIENT)
雖然這可以防止測試出錯,但如果我們能夠以某種方式斷言不可預測的欄位會更好。
4.4.使用自訂比較器
除了STRICT
和LENIENT
模式之外,JSONAssert 還提供自訂選項。雖然它們有局限性,但在這種情況下效果很好:
String body = given()
.get("/build")
.then()
.extract()
.body()
.asString();
JSONAssert.assertEquals(Files.contentOf(new File("src/test/resources/expected-build.json"), "UTF-8"), body,
new CustomComparator(JSONCompareMode.STRICT,
new Customization("build",
new RegularExpressionValueMatcher<>("[0-9a-f-]+")),
new Customization("timestamp",
new RegularExpressionValueMatcher<>(".+"))));
在這裡,我們在build
欄位上新增了Customization
,以符合其中僅包含 UUID 字元的正規表示式,然後自訂timestamp
戳記以符合任何非空白字串。
5. 使用 JsonUnit 進行比較
JsonUnit 是一個年輕的 JSON 斷言庫,受 AssertJ 的影響,專為流暢的斷言而設計。
5.1.新增 JsonUnit
對於流暢的斷言,我們添加JsonUnit AssertJ依賴項:
<dependency>
<groupId>net.javacrumbs.json-unit</groupId>
<artifactId>json-unit-assertj</artifactId>
<version>3.4.1</version>
<scope>test</scope>
</dependency>
5.2.將回應正文與文件進行比較
我們使用assertThatJson()
來啟動一個JSON斷言:
assertThatJson(body)
.isEqualTo(Files.contentOf(new File("src/test/resources/expected-website.json"), "UTF-8"));
這可以處理不同格式的回應以及任意順序的欄位。
5.3.對不可預測的欄位值使用正規表示式
我們可以為 JsonUnit 提供預期輸出,其中包含特殊佔位符,指示與正規表示式相符:
String body = given()
.get("/build")
.then()
.extract()
.body()
.asString();
assertThatJson(body)
.isEqualTo("{\"build\":\"${json-unit.regex}[0-9a-f-]+\",\"timestamp\":\"${json-unit.any-string}\",\"type\":\"website\",\"name\":\"baeldung\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}");
這裡佔位符${json-unit-regex}
是我們的 UUID 模式的前綴。 ${json-unit.any-string}
佔位符與任何字串值成功匹配。
這些佔位符的缺點是它們會透過斷言的控制命令污染預期值。
6. 與模型斷言的比較
ModelAssert 具有與 JSON Assert 和 JsonUnit 類似的功能集。預設情況下,它對響應中鍵的順序敏感。
6.1.新增模型斷言
要使用ModelAssert,我們將其新增到pom.xml
中:
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>model-assert</artifactId>
<version>1.0.3</version>
<scope>test</scope>
</dependency>
6.2.將 JSON 回應正文與文件進行比較
我們使用assertJson()
將字串與預期值進行比較,該預期值可以是一個File
:
String body = given()
.get("/static")
.then()
.extract()
.body()
.asString();
assertJson(body)
.where()
.keysInAnyOrder()
.isEqualTo(new File("src/test/resources/expected-website-different-field-order.json"));
我們不需要使用檔案讀取實用程序,因為 ModelAssert 可以讀取檔案。在此範例中,預期的 JSON 故意採用不同的順序,因此在呼叫isEqualTo()
之前已將where().keysInAnyOrder()
新增至斷言。
6.3.忽略額外字段
模型斷言也可以將欄位的子集與更大的物件進行比較:
assertJson(body)
.where()
.objectContains()
.isEqualTo("{\"type\":\"website\",\"name\":\"baeldung\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}");
objectContains()
規則使 ModelAssert 忽略預期中不存在但實際中存在的任何欄位。
6.4.為不可預測的欄位新增規則
但是,最好自訂 ModelAssert 來斷言存在的字段,即使我們無法預測它們的確切值:
String body = given()
.get("/build")
.then()
.extract()
.body()
.asString();
assertJson(body)
.where()
.keysInAnyOrder()
.path("build").matches("[0-9a-f-]+")
.path("timestamp").matches("[0-9:T.-]+")
.isEqualTo(new File("src/test/resources/expected-build.json"));
這裡的兩個path()
規則為build
和timestamp
字段添加了正規表示式匹配。
7. 更緊密的整合
正如我們之前所看到的, REST-assured 是一個在其body()
方法中支援 Hamcrest 匹配器的斷言庫。要將其與其他 JSON 斷言庫一起使用,我們必須提取回應正文。每個庫都可以用作 Hamcrest 匹配器。根據我們的用例,這可能會使我們的測試程式碼更易於閱讀。
7.1. JSON 斷言 Hamcrest
為此,我們需要由不同貢獻者產生的額外依賴項:
<dependency>
<groupId>uk.co.datumedge</groupId>
<artifactId>hamcrest-json</artifactId>
<version>0.2</version>
</dependency>
這可以很好地處理簡單的用例:
given()
.get("/build")
.then()
.body(sameJSONAs(Files.contentOf(new File("src/test/resources/expected-website.json"), "UTF-8")).allowingExtraUnexpectedFields());
sameJSONAs
使用 JSON Assert 作為引擎建造 Hamcrest 匹配器。然而,它只有有限的客製化選項。在這種情況下,我們只能使用allowExtraUnexpectedFields()
。
7.2. JsonUnit 漢克雷斯特
我們需要從 JsonUnit 專案添加額外的依賴項才能使用 Hamcrest 匹配器:
<dependency>
<groupId>net.javacrumbs.json-unit</groupId>
<artifactId>json-unit</artifactId>
<version>3.4.1</version>
<scope>test</scope>
</dependency>
然後我們可以在 REST-assured 的body()
函數中寫一個斷言匹配器:
given()
.get("/build")
.then()
.body(jsonEquals(Files.contentOf(new File("src/test/resources/expected-website.json"), "UTF-8")).when(Option.IGNORING_EXTRA_FIELDS));
這裡jsonEquals
定義了when()
函式客製的匹配器。
7.3. ModelAssert Hamcrest
ModelAssert 被建構為獨立斷言和 Hamcrest 匹配器。我們使用json()
方法建立 Hamcrest 匹配器:
given()
.get("/build")
.then()
.body(json().where()
.keysInAnyOrder()
.path("build").matches("[0-9a-f-]+")
.path("timestamp").matches("[0-9:T.-]+")
.isEqualTo(new File("src/test/resources/expected-build.json")));
之前的所有自訂選項都可以透過相同的方式使用。
8. 庫的比較
JSONAssert 是最完善的程式庫,但其複雜的自訂以及檢查異常的使用使其使用起來有點繁瑣。
JsonUnit 是一個不斷發展的庫,擁有大量用戶和大量自訂選項。
ModelAssert 對程式設計自訂以及與文件中的預期結果進行比較提供更明確的支援。這是一個不太知名且不太成熟的函式庫。
9. 結論
在本文中,我們研究如何將測試 REST 端點傳回的 JSON 主體與我們可能想要儲存在檔案中的預期 JSON 資料進行比較。
我們研究了無法預測的字段值的挑戰,並研究如何使用 REST-assured 以及三個複雜的 JSON 比較斷言庫本地執行斷言。
最後,我們研究瞭如何透過使用 hamcrest 匹配器將斷言引入 REST-assured 語法中。
與往常一樣,範例程式碼可以在 GitHub 上找到。