webcompere / model-assert

Assertions for data models
MIT License
28 stars 3 forks source link

ModelAssert

Build on Pushcodecov

Assertions for model data. Inspired by JSONAssert and AssertJ. Built on top of Jackson.

Intended as a richer way of writing assertions in unit tests, and as a more powerful alternative to Spring's jsonPath.

Describes paths using JSON Pointer syntax, where a route to the element is a series of / delimited field names or array indices.

Installation

ModelAssert requires Java 8.

Install from Maven Central:

<dependency>
  <groupId>uk.org.webcompere</groupId>
  <artifactId>model-assert</artifactId>
  <version>1.0.3</version>
</dependency>

Quickstart

For a walk-through of key features, there's a tutorial over on Baeldung.com.

Path Assertions

String json = "{\"name\":\"ModelAssert\"}";

// assertJ style
assertJson(json)
   .at("/name").hasValue("ModelAssert");

// hamcrest style
MatcherAssert.assertThat(json,
    json()
      .at("/name").hasValue("ModelAssert"));

In the above example, at is just one of the possible conditions. Here we see Jackson's JSON Pointer syntax in action too.

Whole JSON Comparison

Semantic comparison of the JSON loaded as both expected and actual.

// assertJ style
assertJson("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}")
    .isEqualTo("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}");

// hamcrest style
MatcherAssert.assertThat("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}",
    json().isEqualTo("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}"));

These comparisons can be mixed with path asserts, but they compare the whole object structure and report the differences on error, so there's minimum benefit in using both.

By default, the comparison must match everything in order, but the isEqualTo can be relaxed by using where:

// allow object keys in any order
assertJson("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}")
    .where()
        .keysInAnyOrder()
    .isEqualTo("{\"versions\":[1.00, 1.01, 1.02], \"name\":\"ModelAssert\"}");

See where context for more examples.

Assertion DSL

There are more examples in the unit tests, especially ExamplesTest.

The assertJson methods produce stand-alone assertions which execute each clause in order, stopping on error.

The json* methods - json, jsonNode, jsonFile, jsonFilePath start the construction of a hamcrest matcher to which conditions are added. These are evaluated when the hamcrest matcher's matches is called.

Note: the DSL is intended to provide auto-complete and is largely fluent. It's also composable, so multiple comparisons can be added after the last one is complete:

assertJson(json)
   .at("/name").hasValue("ModelAssert")
   .at("/license").hasValue("MIT")
   .at("/price").isNull();

Non JSON Comparison

If an object can be converted into Jackson's JsonNode structure, which nearly everything can be, then it can be compared using ModelAssert:

Map<String, Object> objectMap = new HashMap<>();
objectMap.put("a", UUID.randomUUID().toString());
objectMap.put("b", UUID.randomUUID().toString());

Map<String, Object> expectedMap = new HashMap<>();
expectedMap.put("a", "");
expectedMap.put("b", "");

assertJson(objectMap)
    .where()
    .path(Pattern.compile("[ab]")).matches(GUID_PATTERN)
    .isEqualTo(expectedMap);

As both assertJson and isEqualTo allow JsonNode as an input, custom conversions to this can be used from any source.

YAML Support

As Jackson can load yaml files, the DSL also supports assertYaml and isEqualToYaml/isNotEqualToYaml:

String yaml1 =
    "name: Mr Yaml\n" +
        "age: 42\n" +
        "items:\n" +
        "  - a\n" +
        "  - b\n";

String yaml2 =
    "name: Mrs Yaml\n" +
        "age: 43\n" +
        "items:\n" +
        "  - c\n" +
        "  - d\n";

assertYaml(yaml1)
    .isNotEqualToYaml(yaml2);

The Hamcrest version of this uses yaml/yamlFile and yamlFilePath:

MatcherAssert.assertThat(yaml1, yaml().isEqualToYaml(yaml2));

Manipulating Json Before or During Assertions

The assertion DSL allows a lot of navigation within the json under test. However, it may be desirable to manually load some json for comparison, and perhaps use only a part of that json:

// load some json to compare against
JsonNode jsonNode = JsonProviders.jsonPathProvider().jsonFrom(jsonFile);

// compare "/child" within a source
assertJson(jsonFile)
    .at("/child")

    // must be equal to the "/child" we've selected
    // from an "actual"
    .isEqualTo(jsonNode.at("/child"));

Building the Assertion

The entry point to creating an assertion is:

After that, there are high level methods to add conditions to the matcher:

When a condition has been added to the assertion then the fluent DSL allows for further conditions to be added.

Note: the assertJson version executes each condition on the fly, where the hamcrest version stores them for execution until the matches method is invoked by MatcherAssert.assertThat or similar.

Conditions

There are multiple contexts from which assertions are available:

Json At

Build a JsonAt condition by using .at("/some/json/pointer").

This is then followed by any of the node context assertions.

Example:

assertJson("{\"name\":null}")
    .at("/name").isNull();

The JsonAt expression is incomplete with just at, but once the rest of the condition is added, the this returned belongs to the main assertion, allowing them to be chained.

assertJson("{\"name\":null}")
    .at("/name").isNull()
    .at("/address").isMissing();

JSON Pointer expressions treat field names and array indices as / delimited:

assertJson("{\"names\":[\"Model\",\"Assert\"]}")
    .at("/names/1").hasValue("Assert");

Node Context Assertions

These are available on any node of the tree, which might be any type. They include the type specific assertions below, as well as:

Text Context Conditions

Numeric Context Conditions

Boolean Context Conditions

Object Context Conditions

Array Context Conditions

There are two main ways to assert the contents of an array. It can be done by value as illustrated above, or it can be done by condition list.

To use the isArrayContaining suite of functions with a condition list, we call conditions() within the ConditionList class to create a fluent builder of a list of conditions. As the fluent builder for assertions adds conditions to the assertion, so the fluent builder inside ConditionList treats each additional condition as an element to search for in the array:

assertJson("[" +
    "{\"name\":\"Model\",\"ok\":true}," +
    "{\"name\":\"Model\",\"ok\":false}," +
    "{\"name\":\"Assert\"}," +
    "{\"age\":1234}" +
    "]")
    .isArrayContainingExactlyInAnyOrder(conditions()
        .at("/name").isText("Assert")
        .at("/name").hasValue("Model")
        .at("/ok").isFalse()
        .at("/age").isNumberEqualTo(1234));

In the above example, the conditions, between them, represent a unique match in each element of the list, but a condition may match more than one element (as .at("/name".isText("Assert") does). This is where the permutational search of the ArrayCondition helps to find the best possible match.

Where a single condition cannot describe the required match for an element then satisfies, which is part of every node, allows a ConditionList:

assertJson("[" +
    "{\"name\":\"Model\",\"ok\":true}," +
    "{\"name\":\"Model\",\"ok\":false}," +
    "{\"name\":\"Model\"}," +
    "{\"age\":1234}" +
    "]")
    .isArrayContainingExactlyInAnyOrder(conditions()
        // condition A
        .at("/name").isText("Model")

        // condition B
        .satisfies(conditions()
            .at("/name").hasValue("Model")
            .at("/ok").isTrue())

        // condition C
        .satisfies(conditions()
            .at("/ok").isFalse()
            .at("/name").isText("Model"))

        // condition D
        .at("/age").isNumberEqualTo(1234));

Each of these composite conditions allows the whole DSL. They're composed together using Condition.and.

A Hamcrest matcher could also be used with ConditionList via matches(Matcher<JsonNode>)

Size Assertions (various types)

Object, String and Array can be said to be sizeable. For Object, the size is the number of keys. For String, it's the number of characters. For Array it's the number of elements.

We can assert this with hasSize:

assertJson("\"some string\"")
    .hasSize(11);

assertJson("[1, 2, 3]")
    .hasSize(3);

The general purpose Number based numeric assertions can be used to assert size via the size() function, which enters the NumberComparison context:

// assert that the array has a size between 3 and 9
assertJson("[1, 2, 3]")
    .size().isBetween(3, 9);

Whole Tree Comparison

The tree comparison is intended to perform a semantic comparison of a JSON tree with another.

It can be used in conjunction with the at part of the Node DSL:

assertJson("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}")
    .at("/versions")
    .isEqualTo("[1.00, 1.01, 1.02]");

It can also be customised using where.

Where Context

This is used to customise how whole tree comparison works.

The where function moves us from node context to customisation of isEqualTo:

assertJson("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}")
    .where()
        .keysInAnyOrder()
    .isEqualTo("{\"versions\":[1.00, 1.01, 1.02], \"name\":\"ModelAssert\"}");

In the where context, we can add general leniency overrides, or specify overrides for particular paths.

Within the path expression, we then add further conditions:

The purpose of the where and path contexts is to allow for things which cannot be predicted at the time of coding, or which do not matter to the result.

A good example is GUIDs in the output. Let's say we have a process which produces JSON with random GUIDs in it. We want to assert that there ARE GUIDs but we can't predict them:

assertJson("{\"a\":{\"guid\":\"fa82142d-13d2-49c4-9878-619c90a9f986\"}," +
    "\"b\":{\"guid\":\"96734f31-33c3-4e50-a72b-49bf2d990e33\"}," +
    "\"c\":{\"guid\":\"064c8c5a-c9c1-4ea0-bf36-1994104aa870\"}}")
    .where()
        .path(ANY_SUBTREE, "guid").matches(GUID_PATTERN)
    .isEqualTo("{\"a\":{\"guid\":\"?\"}," +
        "\"b\":{\"guid\":\"?\"}," +
        "\"c\":{\"guid\":\"?\"}}");

Here, the path(ANY_SUBTREE, "guid").matches(GUID_PATTERN) phrase is allowing anything ending in guid to be matched using matches(GUID_PATTERN) instead of matching it against the JSON inside isEqualTo.

This can be done more specifically using at:

assertJson("{\"a\":{\"guid\":\"fa82142d-13d2-49c4-9878-619c90a9f986\"}," +
    "\"b\":{\"guid\":\"96734f31-33c3-4e50-a72b-49bf2d990e33\"}," +
    "\"c\":{\"guid\":\"064c8c5a-c9c1-4ea0-bf36-1994104aa870\"}}")
    .where()
        .at("/a/guid").matches(GUID_PATTERN)
        .at("/b/guid").matches(GUID_PATTERN)
        .at("/c/guid").matches(GUID_PATTERN)
    .isEqualTo("{\"a\":{\"guid\":\"?\"}," +
        "\"b\":{\"guid\":\"?\"}," +
        "\"c\":{\"guid\":\"?\"}}");

Note: the rules used with where are evaluated in reverse order so the most general should be provided first, and the most specific last.

Loose Array Matching

Warning: performance implications both arrayInAnyOrder and arrayContains try every possible combination of array element in the expected against the actual in order to work out if the expected elements are present. For small arrays, this is not a problem, and the unit tests of this project run very quickly, proving that.

However, an array can, itself, contain objects or other arrays. This can lead to a large permutational explosion, which can take time.

The easiest way to relax array ordering rules is to use where().arrayInAnyOrder() while setting up isEqualTo:

assertJson("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}")
    .where()
    .arrayInAnyOrder()
    .isEqualTo("{\"name\":\"ModelAssert\", \"versions\":[1.02, 1.01, 1.00]}");

If only a specific array may be in a random order, it may be better to specialise this by path:

assertJson("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}")
    .where()
    .path("versions").arrayInAnyOrder()
    .isEqualTo("{\"name\":\"ModelAssert\", \"versions\":[1.02, 1.01, 1.00]}");

And, if the value in the expected doesn't contain all the values from the array in the actual, then we can use arrayContains to both relax the order and allow matching of the ones found:

assertJson("{\"name\":\"ModelAssert\",\"versions\":[1.00, 1.01, 1.02]}")
    .where()
    .path("versions").arrayContains()
    .isEqualTo("{\"name\":\"ModelAssert\", \"versions\":[1.02]}");

Note: loose array comparison also honours the rules set in where for the child nodes of the array. The paths described are routes within the actual tree, not the expected tree.. So as every combination of match is tried, the path rules may perform different comparisons on the expected data, as it's checked against each actual.

Common where Configuration

The configuredBy function on the WhereDsl allows a common comparison configuration to be implemented and plugged in:

@Test
void matchesAnyGuidUsingCommonConfiguration() {
    assertJson("{\"a\":{\"guid\":\"fa82142d-13d2-49c4-9878-619c90a9f986\"}," +
        "\"b\":{\"guid\":\"96734f31-33c3-4e50-a72b-49bf2d990e33\"}," +
        "\"c\":{\"guid\":\"064c8c5a-c9c1-4ea0-bf36-1994104aa870\"}}")
        .where()
            .configuredBy(ExamplesTest::ignoreGuids)
        .isEqualTo("{\"a\":{\"guid\":\"?\"}," +
            "\"b\":{\"guid\":\"?\"}," +
            "\"c\":{\"guid\":\"?\"}}");
}

private static <A> WhereDsl<A> ignoreGuids(WhereDsl<A> where) {
    return where.path(ANY_SUBTREE, "guid").matches(GUID_PATTERN);
}

Customisation

There's room for custom assertions throughout the DSL, and if necessary, the Satisfies interface, allows a condition to be added fluently. Conditions are based on the Condition class. The existing conditions can be used directly if necessary, and can be composed using Condition.and or Condition.or where needed. Similarly, there's a not method in the Condition class Not to invert any condition as well as invert on Condition to invert the current condition.

A custom condition can be fed to satisfies:

// using `and` along with functions from the
// condition classes
assertJson("\"some string\"").satisfies(
    textMatches(Pattern.compile("[a-z ]+"))
        .and(new HasSize(12)));

// using or and inverting the condition - this will
// pass as it fails both the ORed conditions, but the
// whole statement is inverted
assertJson("\"some string!!!\"").satisfies(
    textMatches(Pattern.compile("[a-z ]+"))
        .or(new HasSize(12))
        .inverted());

Interoperability

The assertions can be used stand-alone with assertJson or can be built as Hamcrest matchers. The assertion can also be converted to a Mockito ArgumentMatcher.

Mockito Usage

Assuming Mockito 3, the toArgumentMatcher method converts the Hamcrest style syntax into Mockito's native ArgumentMatcher. Older versions of Mockito used Hamcrest natively.

The json matcher can then be used to detect calls to a function either with verify/then or when setting up responses to different inputs:

// detecting calls based on the json values passed
someInterface.findValueFromJson("{\"name\":\"foo\"}");

then(someInterface)
        .should()
        .findValueFromJson(argThat(json()
        .at("/name").hasValue("foo")
        .toArgumentMatcher()));

// setting up responses based on the json
given(someInterface.findValueFromJson(argThat(json()
        .at("/name").hasValue("foo")
        .toArgumentMatcher())))
        .willReturn("foo");

assertThat(someInterface.findValueFromJson("{\"name\":\"foo\"}")).isEqualTo("foo");

Note, this works with all the types of JSON input sources supported by the Hamcrest version of the library. You need to choose the type of input via the json, jsonFile methods etc.

Interoperability with Spring MVC Matchers

Rather than:

// clause inside ResultMatcher
jsonPath("$.name", "ModelAssert")

We can construct the hamcrest matcher version of ModelAssert's JsonAssertion:

content().string(
    json()
        .at("/name")
        .hasValue("ModelAssert"))

While this syntax is of limited value in this simple case, the more powerful comparisons supported by this library are equally possible after the json() statement starts creating a matcher.

Custom Object Mappers

By default, Model Assert uses two ObjectMapper objects - one for loading JSON and one for loading YAML. These can be overridden for the current thread (allowing concurrent testing) and it's advisable to do this in the @BeforeAll of a text fixture:

@BeforeAll
static void beforeAll() {
    // support LocalDateTime
    overrideObjectMapper(defaultObjectMapper()
        .registerModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS));

    // stop parsing `yes` to mean Boolean `true`
    overrideYamlObjectMapper(new ObjectMapper(
        new YAMLFactory()
            .configure(PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS, true)));
}

and when replacing the object mapper in setup, it's a good idea to put it back in the tear down:

@AfterAll
static void afterAll() {
    clearObjectMapperOverride();
    clearYamlObjectMapperOverride();
}

Any assertions used while the override is in place will use the alternative object mapper.

Note: if using a common alternative object mapper, maybe consider building a small JUnit 5 test extension or use a base class for your tests which contains the common set up

The functions defaultObjectMapper and defaultYamlMapper in JsonProviders can be used to create a basic ObjectMapper to base a custom one on.

API Stability

The classes in the root package uk.org.webcompere.modelassert.json are the jumping on point for the API and they will be changed rarely.

Functions elsewhere will be accessed via the fluent API and may move between packages in later versions, though this should be resolved without changing consuming code.

SemVer numbering will indicate possible breaking changes by increments to the minor version number. Patch versions are unlikely to have any noticeable effect on the API.

Contributing

If you experience any problems using this library, or have any ideas, then please raise an issue. Please check for any existing issues first.

PRs will be accepted if they come with unit tests and are linked to an issue.