1. 概述
测试返回 JSON 的 HTTP 接口时,我们通常需要验证响应体的内容。常见做法是捕获 JSON 示例并保存为格式化的示例文件,用于与实际响应对比。
但这里有个坑:如果返回的 JSON 中某些字段顺序与示例文件不同,或者某些字段值在每次响应时动态变化,直接对比就会失败。
虽然我们可以用 REST-assured 编写断言,但它默认无法完全解决上述问题。本文将探讨如何用 REST-assured 断言 JSON 响应体,并结合 JSONAssert、JsonUnit 和 ModelAssert 处理动态字段或格式差异问题。
2. 示例项目设置
REST-assured 可测试任何 HTTP 服务器,常用于 Spring Boot 和 Micronaut 测试。本文用 WireMock 模拟被测服务。
2.1. 设置 WireMock
在 pom.xml
添加 WireMock 依赖:
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.9.1</version>
<scope>test</scope>
</dependency>
使用 JUnit 5 扩展构建测试:
@WireMockTest
class WireMockTest {
@BeforeEach
void beforeEach(WireMockRuntimeInfo wmRuntimeInfo) {
// 设置 WireMock
}
}
2.2. 添加示例接口
在 beforeEach()
中配置两个接口:
/static
:返回固定数据/build
:包含动态字段
// 固定数据接口
stubFor(get("/static").willReturn(
aResponse()
.withStatus(200)
.withHeader("content-type", "application/json")
.withBody("{\"name\":\"baeldung\",\"type\":\"website\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}")));
// 动态数据接口
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
字段每次请求都会变化。
2.3. 捕获 JSON 响应体
通常我们会捕获接口输出并保存为 JSON 文件作为预期响应:
/static
接口输出:
{
"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 依赖:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
配置 REST-assured 使用 WireMock 暴露的端口:
@BeforeEach
void beforeEach(WireMockRuntimeInfo wmRuntimeInfo) {
RestAssured.port = wmRuntimeInfo.getHttpPort();
}
现在可以开始编写断言了。
3. 开箱即用 REST-assured
REST-assured 提供 given()
/then()
结构设置请求和断言,支持检查响应头、状态码或响应体。我们先看其内置的 JSON 断言功能。
3.1. 断言单个字段
用 body()
方法断言响应体中的单个字段:
given()
.get("/static")
.then()
.body("name", equalTo("baeldung"));
这里使用 JSON Path 表达式定位字段,配合 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 字符串
提取整个响应体后断言:
String body = given()
.get("/static")
.then()
.extract()
.body()
.asString();
assertThat(body)
.isEqualTo("{\"name\":\"baeldung\",\"type\":\"website\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}");
⚠️ 注意:直接断言字符串极易受字段顺序或格式影响。
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")));
4. 使用 JSONAssert 断言
JSONAssert 是历史悠久的 JSON 比较工具,支持自定义比较逻辑以处理格式差异和动态值。
4.1. 与字符串比较
用 assertEquals()
比较响应体:
String body = given()
.get("/static")
.then()
.extract()
.body()
.asString();
JSONAssert.assertEquals("{\"name\":\"baeldung\",\"type\":\"website\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}", body, JSONCompareMode.STRICT);
/static
接口返回完全可预测的数据,使用 STRICT
模式。
⚠️ 注意:JSONAssert 抛出 JSONException
,测试方法需声明 throws Exception
:
@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);
JSONAssert 检查语义等价性而非字符级匹配,格式化差异会被忽略。
4.3. 比较带额外字段的响应
处理动态字段的简单方案是忽略它们。例如比较 /build
响应与 /static
的子集:
JSONAssert.assertEquals(Files.contentOf(new File("src/test/resources/expected-website.json"), "UTF-8"), body, JSONCompareMode.LENIENT)
但更推荐的做法是对动态字段进行适当断言。
4.4. 使用自定义比较器
通过 CustomComparator
处理动态字段:
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
字段添加 UUID 格式匹配,为 timestamp
匹配任意非空字符串。
5. 使用 JsonUnit 比较
JsonUnit 是受 AssertJ 启发的现代 JSON 断言库,提供流式 API。
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()
开始断言:
assertThatJson(body)
.isEqualTo(Files.contentOf(new File("src/test/resources/expected-website.json"), "UTF-8"));
自动处理字段顺序和格式差异。
5.3. 用正则表达式处理动态值
在预期 JSON 中使用特殊占位符匹配正则表达式:
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}
前缀指定正则模式,${json-unit.any-string}
匹配任意字符串。
❌ 缺点:占位符污染了预期数据的可读性。
6. 使用 Model Assert 比较
ModelAssert 功能类似 JSONAssert 和 JsonUnit,默认对字段顺序敏感。
6.1. 添加 Model Assert
添加 ModelAssert 依赖:
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>model-assert</artifactId>
<version>1.0.3</version>
<scope>test</scope>
</dependency>
6.2. 与文件比较
用 assertJson()
比较,直接支持文件路径:
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 字段顺序不同,通过 keysInAnyOrder()
忽略顺序。
6.3. 忽略额外字段
比较子集时忽略多余字段:
assertJson(body)
.where()
.objectContains()
.isEqualTo("{\"type\":\"website\",\"name\":\"baeldung\",\"text\":{\"language\":\"english\",\"code\":\"java\"}}");
objectContains()
规则使实际响应中的额外字段被忽略。
6.4. 为动态字段添加规则
更推荐用规则断言动态字段:
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 支持 Hamcrest 匹配器,此前我们需提取响应体使用各库断言。现在展示如何直接在 REST-assured 中集成这些库。
7.1. JSON Assert 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
构建 Hamcrest 匹配器,但自定义选项有限,仅支持 allowExtraUnexpectedFields()
。
7.2. JsonUnit Hamcrest
添加 JsonUnit 核心依赖:
<dependency>
<groupId>net.javacrumbs.json-unit</groupId>
<artifactId>json-unit</artifactId>
<version>3.4.1</version>
<scope>test</scope>
</dependency>
在 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()
创建匹配器:
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 响应与存储在文件中的预期数据进行比较。重点解决了动态字段值和格式差异带来的挑战,介绍了 REST-assured 原生断言及三个专业 JSON 比较库的解决方案。最后展示了通过 Hamcrest 匹配器与 REST-assured 深度集成的技术。
完整示例代码见 GitHub 仓库。