Course – LS (cat=JSON/Jackson)

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

When writing automated tests for software that uses JSON, we often need to compare JSON data with some expected value.

In some cases, we can treat the actual and expected JSON as strings and perform string comparison, but this has many limitations.

In this tutorial, we’ll look at how to write assertions and comparisons between JSON values using ModelAssert. We’ll see how to construct assertions on individual values within a JSON document and how to compare documents. We’ll also cover how to handle fields whose exact values cannot be predicted, such as dates or GUIDs.

2. Getting Started

ModelAssert is a data assertion library with a syntax similar to AssertJ and features comparable to JSONAssert. It’s based on Jackson for JSON parsing and uses JSON Pointer expressions to describe paths to fields in the document.

Let’s start by writing some simple assertions for this JSON:

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

2.1. Dependency

To start, let’s add ModelAssert to our pom.xml:

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

2.2. Assert a Field in a JSON Object

Let’s imagine that the example JSON has been returned to us as a String, and we want to check that the name field is equal to Baeldung:

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

The assertJson method will read JSON from various sources, including StringFilePath, and Jackson’s JsonNode. The object returned is an assertion, upon which we can use the fluent DSL (domain-specific language) to add conditions.

The at method describes a place within the document where we wish to make a field assertion. Then, isText specifies that we expect a text node with the value Baeldung.

We can assert a path within the topics array by using a slightly longer JSON Pointer expression:

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

While we can write field assertions one by one, we can also combine them into a single assertion:

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

2.3. Why String Comparison Doesn’t Work

Often we want to compare a whole JSON document with another. String comparison, though possible in some cases, often gets caught out by irrelevant JSON formatting issues:

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

A failure message like this is common:

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. Comparing Trees Semantically

To make a whole document comparison, we can use isEqualTo:

assertJson(jsonString)
  .isEqualTo(EXPECTED_JSON_PATH);

In this instance, the string of the actual JSON is loaded by assertJson, and the expected JSON document – a file described by a Path – is loaded inside the isEqualTo. The comparison is made based on the data.

2.5. Different Formats

ModelAssert also supports Java objects that can be converted to JsonNode by Jackson, as well as the yaml format.

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

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

For yaml handling, the isEqualToYaml method is used to indicate the format of the string or file. This requires assertYaml if the source is yaml:

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

3. Field Assertions

So far, we’ve seen some basic assertions. Let’s look at more of the DSL.

3.1. Asserting at Any Node

The DSL for ModelAssert allows nearly every possible condition to be added against any node in the tree. This is because JSON trees may contain nodes of any type at any level.

Let’s look at some assertions we might add to the root node of our example JSON:

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

As the assertion object has these methods available on its interface, our IDE will suggest the various assertions we can add the moment we press the “.” key.

In this example, we have added lots of unnecessary conditions since the last condition already implies a non-null object.

Most often, we use JSON Pointer expressions from the root node in order to perform assertions on nodes lower down the tree:

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

This assertion uses hasSize to check that the array in the topic field has five elements. The hasSize method operates on objects, arrays, and strings. An object’s size is its number of keys, a string’s size is its number of characters, and an array’s size is its number of elements.

Most assertions we need to make on fields depend on the exact type of the field. We can use the methods numberarraytextbooleanNode, and object to move into a more specific subset of the assertions when we’re trying to write assertions on a particular type. This is optional but can be more expressive:

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

When we press the “.” key in our IDE after booleanNode, we only see autocomplete options for boolean nodes.

3.2. Text Node

When we’re asserting text nodes, we can use isText to compare using an exact value. Alternatively, we can use textContains to assert a substring:

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

We can also use regular expressions via matches:

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

This example asserts that the name starts with a capital letter.

3.3. Number Node

For number nodes, the DSL provides some useful numeric comparisons:

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

We can also specify the Java numeric type we’re expecting:

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

The isEqualTo method is reserved for whole tree matching, so for comparing numeric equality, we use isNumberEqualTo:

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

3.4. Array Node

We can test the contents of an array with isArrayContaining:

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

This tests for the presence of the given values and allows the actual array to contain additional items. If we wish to assert a more exact match, we can use isArrayContainingExactlyInAnyOrder:

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

We can also make this require the exact order:

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

This is a good technique for asserting the contents of arrays that contain primitive values. Where an array contains objects, we may wish to use isEqualTo instead.

4. Whole Tree Matching

While we can construct assertions with multiple field-specific conditions to check out what’s in the JSON document, we often need to compare a whole document against another.

The isEqualTo method (or isNotEqualTo) is used to compare the whole tree. This can be combined with at to move to a subtree of the actual before making the comparison:

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

Whole tree comparison can hit problems when the JSON contains data that is either:

  • the same, but in a different order
  • comprised of some values that cannot be predicted

The where a method is used to customize the next isEqualTo operation to get around these.

4.1. Add Key Order Constraint

Let’s look at two JSON documents that seem the same:

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

We should note that this isn’t strictly JSON format. ModelAssert allows us to use the JavaScript notation for JSON, as well as the wire format that usually quotes the field names.

These two documents have exactly the same keys underneath “a”, but they’re in a different order. An assertion of these would fail, as ModelAssert defaults to strict key order.

We can relax the key order rule by adding a where configuration:

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

This allows any object in the tree to have a different order of keys from the expected document and still match.

We can localize this rule to a specific path:

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

This limits the keysInAnyOrder to just the “a” field in the root object.

The ability to customize the comparison rules allows us to handle many scenarios where the exact document produced cannot be fully controlled or predicted.

4.2. Relaxing Array Constraints

If we have arrays where the order of values can vary, then we can relax the array ordering constraint for the whole comparison:

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

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

Or we can limit that constraint to a path, as we did with keysInAnyOrder.

4.3. Ignoring Paths

Maybe our actual document contains some fields that are either uninteresting or unpredictable. We can add a rule to ignore that path:

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

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

We should note that the path we’re expressing is always in terms of the JSON Pointer within the actual.

The extra field “url” in the actual is now ignored.

4.4. Ignore Any GUID

So far, we’ve only added rules using at in order to customize comparison at specific locations in the document.

The path syntax allows us to describe where our rules apply using wildcards. When we add an at or path condition to the where of our comparison, we can also provide any of the field assertions from above to use in place of a side-by-side comparison with the expected document.

Let’s say we had an id field that appeared in multiple places in our document and was a GUID that we couldn’t predict.

We could ignore this field with a path rule:

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);

Here, our expected value could have anything for the id field because we’ve simply ignored any field whose JSON Pointer starts “/user/credentials” then has a single node (the array index) and ends in “/id”.

4.5. Match Any GUID

Ignoring fields we can’t predict is one option. It’s better instead to match those nodes by type, and maybe also by some other condition they must meet. Let’s switch to forcing those GUIDs to match the pattern of a GUID, and let’s allow the id node to appear at any leaf node of the tree:

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

The ANY_SUBTREE wildcard matches any number of nodes between parts of the path expression. The GUID_PATTERN comes from the ModelAssert Patterns class, which contains some common regular expressions to match things like numbers and date stamps.

4.6. Customizing isEqualTo

The combination of where with either path or at expressions allows us to override comparisons anywhere in the tree. We either add the built-in rules for an object or array matching or specify specific alternative assertions to use for individual or classes of paths within the comparison.

Where we have a common configuration, reused across various comparisons, we can extract it into a method:

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

Then, we can add that configuration to a particular assertion with configuredBy:

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

5. Compatibility with Other Libraries

ModelAssert was built for interoperability. So far, we’ve seen the AssertJ style assertions. These can have multiple conditions, and they will fail on the first condition that’s not met.

However, sometimes we need to produce a matcher object for use with other types of tests.

5.1. Hamcrest Matcher

Hamcrest is a major assertion helper library supported by many tools. We can use the DSL of ModelAssert to produce a Hamcrest matcher:

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

The json method is used to describe a matcher that will accept a String with JSON data in it. We could also use jsonFile to produce a Matcher that expects to assert the contents of a File. The JsonAssertions class in ModelAssert contains multiple builder methods like this to start building a Hamcrest matcher.

The DSL for expressing the comparison is identical to assertJson, but the comparison isn’t executed until something uses the matcher.

We can, therefore, use ModelAssert with Hamcrest’s MatcherAssert:

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

5.2. Using With Spring Mock MVC

While using response body verification in Spring Mock MVC, we can use Spring’s built-in jsonPath assertions. However, Spring also allows us to use Hamcrest matchers to assert the string returned as response content. This means we can perform sophisticated content assertions using ModelAssert.

5.3. Use With Mockito

Mockito is already interoperable with Hamcrest. However, ModelAssert also provides a native ArgumentMatcher. This can be used both to set up the behavior of stubs and to verify calls to them:

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()));
}

In this example, the Mockito argThat is used in both the setup of a mock and the verify. Inside that, we use the Hamcrest style builder for the matcher – json. Then we add conditions to it, converting to Mockito’s ArgumentMatcher at the end with toArgumentMatcher.

6. Conclusion

In this article, we looked at the need to compare JSON semantically in our tests.

We saw how ModelAssert can be used to build an assertion on individual nodes within a JSON document as well as whole trees. Then we saw how to customize tree comparison to allow for unpredictable or irrelevant differences.

Finally, we saw how to use ModelAssert with Hamcrest and other libraries.

As always, the example code from this tutorial is available over on GitHub.

Course – LS (cat=JSON/Jackson)

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.