1. 概述

在为使用 JSON 的软件编写自动化测试时,我们经常需要将 JSON 数据与某些期望值进行比较。

在某些情况下,我们可以将实际的和期望的 JSON 视为字符串并进行字符串比较,但这有很多限制。

在本教程中,我们将了解如何使用ModelAssert编写断言和 JSON 值之间的比较。我们将了解如何对 JSON 文档中的各个值构建断言以及如何比较文档。我们还将介绍如何处理无法预测确切值的字段,例如日期或 GUID。

2. 入门

ModelAssert 是一个数据断言库,其语法类似于AssertJ ,功能类似于JSONAssert 。它基于Jackson进行 JSON 解析,并使用JSON 指针表达式来描述文档中字段的路径。

让我们首先为此 JSON 编写一些简单的断言:

{
   "name": "Baeldung",
   "isOnline": true,
   "topics": [ "Java", "Spring", "Kotlin", "Scala", "Linux" ]
}

2.1.依赖性

首先,让我们将ModelAssert添加到 pom.xml 中:

<dependency>
    <groupId>uk.org.webcompere</groupId>
    <artifactId>model-assert</artifactId>
    <version>1.0.0</version>
    <scope>test</scope>
</dependency>

2.2.断言 JSON 对象中的字段

让我们假设示例 JSON 已作为 字符串返回给我们, 我们想要检查 name 字段是否等于 Baeldung

assertJson(jsonString)
  .at("/name").isText("Baeldung");

assertJson 方法将从各种来源读取 JSON,包括 StringFilePath 和 Jackson 的 JsonNode 。返回的对象是一个断言,我们可以在断言上使用流畅的 DSL(特定于领域的语言)来添加条件。

at 方法描述了文档中我们希望进行字段断言的位置。然后, isText 指定我们期望一个值为 Baeldung 的 文本节点。

我们可以使用稍长的 JSON 指针表达式来断言 主题 数组中的路径:

assertJson(jsonString)
  .at("/topics/1").isText("Spring");

虽然我们可以一一编写字段断言, 但我们也可以将它们组合成一个断言

assertJson(jsonString)
  .at("/name").isText("Baeldung")
  .at("/topics/1").isText("Spring");

2.3.为什么字符串比较不起作用

通常我们想要将整个 JSON 文档与另一个文档进行比较。字符串比较虽然在某些情况下是可能的,但经常会 因不相关的 JSON 格式问题而陷入困境

String expected = loadFile(EXPECTED_JSON_PATH);
assertThat(jsonString)
  .isEqualTo(expected);

像这样的失败消息很常见:

org.opentest4j.AssertionFailedError: 
expected: "{
    "name": "Baeldung",
    "isOnline": true,
    "topics": [ "Java", "Spring", "Kotlin", "Scala", "Linux" ]
}"
but was : "{"name": "Baeldung","isOnline": true,"topics": [ "Java", "Spring", "Kotlin", "Scala", "Linux" ]}"

2.4.从语义上比较树

要进行整个文档比较,我们可以使用 isEqualTo

assertJson(jsonString)
  .isEqualTo(EXPECTED_JSON_PATH);

在本例中,实际 JSON 的字符串由 assertJson 加载,并且预期的 JSON 文档(由 Path 描述的文件)加载到 isEqualTo 内。比较是根据数据进行的。

2.5.不同的格式

ModelAssert 还支持可由 Jackson 转换为 JsonNode 的 Java 对象,以及 yaml 格式。

Map<String, String> map = new HashMap<>();
map.put("name", "baeldung");

assertJson(map)
  .isEqualToYaml("name: baeldung");

对于 yaml 处理, isEqualToYaml 方法用于指示字符串或文件的格式。如果源是 yaml ,则需要 assertYaml

assertYaml("name: baeldung")
  .isEqualTo(map);

3. 字段断言

到目前为止,我们已经看到了一些基本的断言。让我们进一步了解 DSL。

3.1.在任意节点断言

ModelAssert 的 DSL 允许针对树中的任何节点添加几乎所有可能的条件。这是因为 JSON 树可能包含任何级别的任何类型的节点。

让我们看一下可能添加到示例 JSON 根节点的一些断言:

assertJson(jsonString)
  .isNotNull()
  .isNotNumber()
  .isObject()
  .containsKey("name");

由于断言对象在其接口上提供了这些方法,因此我们的 IDE 将建议我们在按下 “.” 时可以添加的各种断言。钥匙。

在此示例中,我们添加了许多不必要的条件,因为最后一个条件已经暗示了一个非空对象。

最常见的是,我们使用根节点中的 JSON 指针表达式,以便对树下方的节点执行断言:

assertJson(jsonString)
  .at("/topics").hasSize(5);

此断言使用 hasSize 检查 主题 字段中的数组是否有五个元素。 hasSize 方法对对象、数组和字符串进行操作。对象的大小是其键的数量,字符串的大小是其字符的数量,数组的大小是其元素的数量。

我们需要对字段进行的大多数断言取决于字段的确切类型。当我们尝试在特定类型上编写断言时,我们可以使用 numberarraytextbooleanNodeobject 方法移动到更具体的断言子集。这是可选的,但可以更具表现力:

assertJson(jsonString)
  .at("/isOnline").booleanNode().isTrue();

当我们按下 “.” 时在我们的 IDE 中键入 booleanNode 之后,我们只能看到布尔节点的自动完成选项。

3.2.文本节点

当我们断言文本节点时,我们可以使用 isText 来使用精确值进行比较。或者,我们可以使用 textContains 来断言子字符串:

assertJson(jsonString)
  .at("/name").textContains("ael");

我们还可以通过 matches 使用正则表达式

assertJson(jsonString)
  .at("/name").matches("[A-Z].+");

此示例断言 名称 以大写字母开头。

3.3.数字节点

对于数字节点,DSL 提供了一些有用的数字比较:

assertJson("{count: 12}")
  .at("/count").isBetween(1, 25);

我们还可以指定我们期望的 Java 数字类型:

assertJson("{height: 6.3}")
  .at("/height").isGreaterThanDouble(6.0);

isEqualTo 方法保留用于整个树匹配,因此为了比较数字相等,我们使用 isNumberEqualTo

assertJson("{height: 6.3}")
  .at("/height").isNumberEqualTo(6.3);

3.4.数组节点

我们可以使用 isArrayContaining 测试数组的内容:

assertJson(jsonString)
  .at("/topics").isArrayContaining("Scala", "Spring");

这会测试给定值是否存在,并允许实际数组包含其他项目。如果我们希望断言更精确的匹配,我们可以使用 isArrayContainingExactlyInAnyOrder

assertJson(jsonString)
   .at("/topics")
   .isArrayContainingExactlyInAnyOrder("Scala", "Spring", "Java", "Linux", "Kotlin");

我们还可以要求精确的顺序:

assertJson(ACTUAL_JSON)
  .at("/topics")
  .isArrayContainingExactly("Java", "Spring", "Kotlin", "Scala", "Linux");

这是断言包含原始值的数组内容的好技术。当数组包含对象时,我们可能希望使用 isEqualTo 来代替。

4. 整树匹配

虽然我们可以使用多个特定于字段的条件构建断言来检查 JSON 文档中的内容,但我们经常需要将整个文档与另一个文档进行比较。

isEqualTo 方法(或 isNotEqualTo )用于比较整个树。这可以与 at 结合使用,在进行比较之前移动到实际的子树:

assertJson(jsonString)
  .at("/topics")
  .isEqualTo("[ \"Java\", \"Spring\", \"Kotlin\", \"Scala\", \"Linux\" ]");

当 JSON 包含以下任一数据时,整个树比较可能会遇到问题:

  • 相同,但顺序不同
  • 由一些无法预测的值组成

其中 使用一个方法来自定义下一个 isEqualTo 操作来解决这些问题。

4.1.添加键顺序约束

让我们看一下两个看起来相同的 JSON 文档:

String actualJson = "{a:{d:3, c:2, b:1}}";
String expectedJson = "{a:{b:1, c:2, d:3}}";

我们应该注意,这并不是严格的 JSON 格式。 ModelAssert 允许我们使用 JSON 的 JavaScript 表示法 ,以及通常引用字段名称的有线格式。

这两个文档在 “a” 下面具有完全相同的键,但它们的顺序不同。这些断言将会失败,因为 ModelAssert 默认为严格的键顺序

我们可以通过添加 where 配置来放宽键顺序规则:

assertJson(actualJson)
  .where().keysInAnyOrder()
  .isEqualTo(expectedJson);

这允许树中的任何对象具有与预期文档不同的键顺序并且仍然匹配。

我们可以将此规则本地化到特定路径:

assertJson(actualJson)
  .where()
    .at("/a").keysInAnyOrder()
  .isEqualTo(expectedJson);

这将 keysInAnyOrder 限制为根对象中的 “a” 字段。

自定义比较规则的能力使我们能够处理许多无法完全控制或预测生成的确切文档的场景

4.2.放宽数组约束

如果我们的数组中的值顺序可以变化,那么我们可以放宽整个比较的数组顺序约束:

String actualJson = "{a:[1, 2, 3, 4, 5]}";
String expectedJson = "{a:[5, 4, 3, 2, 1]}";

assertJson(actualJson)
  .where().arrayInAnyOrder()
  .isEqualTo(expectedJson);

或者我们可以将该约束限制为路径,就像我们对 keysInAnyOrder 所做的那样。

4.3.忽略路径

也许我们的实际文档包含一些无趣或不可预测的字段。我们可以添加一条规则来忽略该路径:

String actualJson = "{user:{name: \"Baeldung\", url:\"http://www.baeldung.com\"}}";
String expectedJson = "{user:{name: \"Baeldung\"}}";

assertJson(actualJson)
  .where()
    .at("/user/url").isIgnored()
  .isEqualTo(expectedJson);

我们应该注意,我们表达的路径 始终是根据实际 .

实际中的额外字段 “url” 现在被忽略。

4.4.忽略任何 GUID

到目前为止,我们仅添加了使用 at 的 规则,以便在文档中的特定位置自定义比较。

路径 语法允许我们使用通配符来描述规则的应用位置。当我们将 atpath 条件添加到比较的 where 时, 我们还可以提供上面的任何字段断言 来代替与预期文档的并排比较。

假设我们有一个 id 字段出现在文档中的多个位置,并且是一个我们无法预测的 GUID。

我们可以使用路径规则忽略该字段:

String actualJson = "{user:{credentials:[" +
  "{id:\"a7dc2567-3340-4a3b-b1ab-9ce1778f265d\",role:\"Admin\"}," +
  "{id:\"09da84ba-19c2-4674-974f-fd5afff3a0e5\",role:\"Sales\"}]}}";
String expectedJson = "{user:{credentials:" +
  "[{id:\"???\",role:\"Admin\"}," +
  "{id:\"???\",role:\"Sales\"}]}}";

assertJson(actualJson)
  .where()
    .path("user","credentials", ANY, "id").isIgnored()
  .isEqualTo(expectedJson);

在这里,我们的预期值可以是 id 字段的任何值,因为我们只是忽略了 JSON 指针以 “/user/credentials” 开头、然后具有单个节点(数组索引)并以 “/id” 结尾的任何字段。

4.5.匹配任何 GUID

忽略我们无法预测的字段是一种选择。最好按类型匹配这些节点,也许还可以按它们必须满足的其他条件进行匹配。让我们转而强制这些 GUID 与 GUID 的模式匹配,并允许 id 节点出现在树的任何叶节点上:

assertJson(actualJson)
  .where()
    .path(ANY_SUBTREE, "id").matches(GUID_PATTERN)
  .isEqualTo(expectedJson);

ANY_SUBTREE 通配符匹配路径表达式各部分之间的任意数量的节点。 GUID_PATTERN 来自 ModelAssert Patterns 类,其中包含一些常见的正则表达式来匹配数字和日期戳等内容。

4.6.自定义 isEqualTo

where路径at 表达式的组合允许我们覆盖树中任何位置的比较。我们要么添加对象或数组匹配的内置规则,要么指定特定的替代断言以用于比较中的单个路径或路径类。

如果我们有一个通用配置,可以在各种比较中重用,我们可以将其提取到一个方法中:

private static <T> WhereDsl<T> idsAreGuids(WhereDsl<T> where) {
    return where.path(ANY_SUBTREE, "id").matches(GUID_PATTERN);
}

然后,我们可以使用 configuredBy 将该配置添加到特定断言中:

assertJson(actualJson)
  .where()
    .configuredBy(where -> idsAreGuids(where))
  .isEqualTo(expectedJson);

5. 与其他库的兼容性

ModelAssert 是为了互操作性而构建的。到目前为止,我们已经看到了 AssertJ 风格的断言。这些可以有多个条件,如果 第一个条件不满足,它们就会失败。

然而,有时我们需要生成一个匹配器对象以用于其他类型的测试。

5.1.汉克雷斯特匹配器

Hamcrest是一个受许多工具支持的主要断言帮助器库。 我们可以使用 ModelAssert 的 DSL 来生成 Hamcrest 匹配器

Matcher<String> matcher = json()
  .at("/name").hasValue("Baeldung");

json 方法用于描述一个匹配器,该匹配器将接受包含 JSON 数据的 字符串 。我们还可以使用 jsonFile 生成一个 Matcher 来断言 File 的内容。 ModelAssert 中的 JsonAssertions 类包含多个像这样的构建器方法来开始构建 Hamcrest 匹配器。

用于表达比较的DSL与 assertJson 相同,但是只有在使用匹配器时才会执行比较。

因此,我们可以将 ModelAssert 与 Hamcrest 的 MatcherAssert 一起使用:

MatcherAssert.assertThat(jsonString, json()
  .at("/name").hasValue("Baeldung")
  .at("/topics/1").isText("Spring"));

5.2.与 Spring Mock MVC 一起使用

在 Spring Mock MVC 中使用响应体验证时,我们可以使用 Spring 内置的 jsonPath 断言。然而,Spring 还允许我们使用Hamcrest 匹配器来断言作为响应内容返回的字符串。这意味着我们可以使用 ModelAssert 执行复杂的内容断言。

5.3.与 Mockito 一起使用

Mockito 已经可以与 Hamcrest 互操作。但是,ModelAssert 还提供了一个本机 ArgumentMatcher 。这可以用来设置存根的行为并验证对它们的调用:

public interface DataService {
    boolean isUserLoggedIn(String userDetails);
}

@Mock
private DataService mockDataService;

@Test
void givenUserIsOnline_thenIsLoggedIn() {
    given(mockDataService.isUserLoggedIn(argThat(json()
      .at("/isOnline").isTrue()
      .toArgumentMatcher())))
      .willReturn(true);

    assertThat(mockDataService.isUserLoggedIn(jsonString))
      .isTrue();

    verify(mockDataService)
      .isUserLoggedIn(argThat(json()
        .at("/name").isText("Baeldung")
        .toArgumentMatcher()));
}

在此示例中,Mockito argThat 用于模拟和 验证 的设置。在其中,我们使用 Hamcrest 样式构建器作为匹配器 - json 。然后我们向它添加条件,最后用 toArgumentMatcher 转换为 Mockito 的 ArgumentMatcher

六,结论

在本文中,我们研究了在测试中对 JSON 进行语义比较的必要性。

我们了解了如何使用 ModelAssert 在 JSON 文档中的各个节点以及整个树上构建断言。然后我们了解了如何自定义树比较以允许不可预测或不相关的差异。

最后,我们了解了如何将 ModelAssert 与 Hamcrest 和其他库一起使用。

与往常一样,本教程中的示例代码可在 GitHub 上获取。