1. 概述

测试返回 JSON 的 HTTP 接口时,我们通常需要验证响应体的内容。常见做法是捕获 JSON 示例并保存为格式化的示例文件,用于与实际响应对比。

但这里有个坑:如果返回的 JSON 中某些字段顺序与示例文件不同,或者某些字段值在每次响应时动态变化,直接对比就会失败。

虽然我们可以用 REST-assured 编写断言,但它默认无法完全解决上述问题。本文将探讨如何用 REST-assured 断言 JSON 响应体,并结合 JSONAssertJsonUnitModelAssert 处理动态字段或格式差异问题。

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\"}}")));

其中 buildtimestamp 字段每次请求都会变化。

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()buildtimestamp 添加正则匹配规则。

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 仓库


原始标题:Asserting REST JSON Responses With REST-assured | Baeldung