Skip to content

Commit

Permalink
Support for ignoring unknown keys in JSON asserts (#2303)
Browse files Browse the repository at this point in the history
* Support for ignoring unknown keys in JSON asserts

Added new matchers `shouldEqualJsonIgnoringUnknown` and
`shouldNotEqualJsonIgnoringUnknown`. Supports for specifying all
other flags that the previous json matcher supported.

The existing compare method already supported the use-case quite easily,
and had good error messages for possible validation errors.

Unknown keys can only exist in object nodes. The scenarios currently
covered by tests are:

* Actual contains a key not specified in expected - key is ignored
* Actual contains a key specified in expected - key is compared
* Actual is missing a key specified in expected - matcher error occurs

Since comparison happens recursively, I also added tests to make sure
the ignoring is in effect deeper in the structure as well.

* Renamed assertion and flag

* Updated docs

* Updated names according to PR discussion

* Fix merge issue

Co-authored-by: Sam Sam <sam@sksamuel.com>
  • Loading branch information
Kantis and sksamuel committed Sep 18, 2021
1 parent 22dcbe5 commit 4ae2869
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 21 deletions.
20 changes: 19 additions & 1 deletion documentation/docs/assertions/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@ a.shouldEqualJson(b, CompareOrder.LenientAll)

Targets: **JVM**, **JS**

## shouldEqualSpecifiedJson
Behaves a lot like `shouldEqualJson`, but ignores extra keys present in the actual structure. By comparison, `shouldEqualJson` requires the entire structure to match. Using `shouldEqualSpecifiedJson` will make the comparison use only specified fields, for example:

```kotlin
val a = """ { "a": true, "date": "2019-11-03" } """
val b = """ { "a": true } """

// this would pass
a shouldEqualSpecifiedJson b

// this would fail
a shouldEqualJson b
```

`shouldEqualSpecifiedJson` also supports the `CompareMode` and `CompareOrder` parameters.

Targets: **JVM**, **JS**

## shouldContainJsonKey

`json?.shouldContainJsonKey("$.json.path")` asserts that a JSON string contains the given JSON path.
Expand All @@ -131,7 +149,7 @@ Targets: **JVM**

## shouldMatchJsonResource

`json?.shouldContainJsonKey("$.json.path")` asserts that the JSON is equal to the existing `/file.json` ignoring properties' order and formatting.
`json?.shouldMatchJsonResource("/file.json")` asserts that the JSON is equal to the existing test reosource `/file.json`, ignoring properties' order and formatting.

Targets: **JVM**

Expand Down
2 changes: 2 additions & 0 deletions documentation/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ _**Kotlin 1.5 is now the minimum supported version**_
* `Arb.values` has been removed. This was deprecated in 4.3 in favour of `Arb.sample`. Any custom arbs that override this method should be updated. Any custom arbs that use the `arbitrary` builders are not affected. (#2277)
* The Engine no longer logs config to the console during start **by default**. To enable, set the system property `kotest.framework.dump.config` to true. (#2276)
* Removed deprecated `shouldReceiveWithin` and `shouldReceiveNoElementsWithin` channel matchers.
* `equalJson` has an added parameter to support the new `shouldEqualSpecifiedJson` assertion
* `TestEngineListener` methods are now suspendable. This is only of interest if you have implemented customizations of the Test Engine through plugins. Note: This is not related to public TestListener methods that are used by test cases.
* The global configuration variable `project` has been removed. This was deprecated in 4.2. Use the replacment global variable `configuration` or provide config via instances of [ProjectConfig](https://kotest.io/docs/framework/project-config.html).
* The deprecated `RuntimeTagExtension` has been undeprecated but moved to a new package.
Expand Down Expand Up @@ -43,6 +44,7 @@ _**Kotlin 1.5 is now the minimum supported version**_
* Add unsigned types for Arb (#2290)
* Added arb for ip addresses V4 #2407
* Added arb for hexidecimal codepoints #2409
* Added `shouldEqualSpecifiedJson` to match a JSON structure on a subset of (specified) keys. (#2298)

#### Deprecations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,22 @@ fun matchJson(expected: String?) = object : Matcher<String?> {
*/
fun String.shouldEqualJson(expected: String, mode: CompareMode, order: CompareOrder) {
val (e, a) = parse(expected, this)
a should equalJson(e, mode, order)
a should equalJson(e, mode, order, FieldComparison.Exact)
}

fun String.shouldNotEqualJson(expected: String, mode: CompareMode, order: CompareOrder) {
val (e, a) = parse(expected, this)
a shouldNot equalJson(e, mode, order)
a shouldNot equalJson(e, mode, order, FieldComparison.Exact)
}

fun String.shouldEqualSpecifiedJson(expected: String, mode: CompareMode, order: CompareOrder) {
val (e,a) = parse(expected, this)
a should equalJson(e, mode, order, FieldComparison.IgnoreExtra)
}

fun String.shouldNotEqualSpecifiedJson(expected: String, mode: CompareMode, order: CompareOrder) {
val (e,a) = parse(expected, this)
a shouldNot equalJson(e, mode, order, FieldComparison.IgnoreExtra)
}

internal fun parse(expected: String, actual: String): Pair<JsonTree, JsonTree> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ enum class CompareOrder {
LenientAll,
}

enum class FieldComparison {
/** Verifies all expected key-values match, and that no extra keys exist in the actual */
Exact,
/** Verifies all expected key-values match, ignoring keys in actual that are not specified in expected */
IgnoreExtra
}

/**
* Compares two json trees, returning a detailed error message if they differ.
*/
Expand All @@ -75,15 +82,16 @@ internal fun compare(
expected: JsonNode,
actual: JsonNode,
mode: CompareMode,
order: CompareOrder
order: CompareOrder,
fieldComparison: FieldComparison,
): JsonError? {
return when (expected) {
is JsonNode.ObjectNode -> when (actual) {
is JsonNode.ObjectNode -> compareObjects(path, expected, actual, mode, order)
is JsonNode.ObjectNode -> compareObjects(path, expected, actual, mode, order, fieldComparison)
else -> JsonError.ExpectedObject(path, actual)
}
is JsonNode.ArrayNode -> when (actual) {
is JsonNode.ArrayNode -> compareArrays(path, expected, actual, mode, order)
is JsonNode.ArrayNode -> compareArrays(path, expected, actual, mode, order, fieldComparison)
else -> JsonError.ExpectedArray(path, actual)
}
is JsonNode.BooleanNode -> compareBoolean(path, expected, actual, mode)
Expand All @@ -99,35 +107,38 @@ internal fun compareObjects(
actual: JsonNode.ObjectNode,
mode: CompareMode,
order: CompareOrder,
fieldComparison: FieldComparison,
): JsonError? {

val keys1 = expected.elements.keys
val keys2 = actual.elements.keys
if (fieldComparison == FieldComparison.Exact) {
val keys1 = expected.elements.keys
val keys2 = actual.elements.keys

if (keys1.size < keys2.size) {
val missing = keys2 - keys1
return JsonError.ObjectMissingKeys(path, missing)
}
if (keys1.size < keys2.size) {
val missing = keys2 - keys1
return JsonError.ObjectMissingKeys(path, missing)
}

if (keys2.size < keys1.size) {
val extra = keys1 - keys2
return JsonError.ObjectExtraKeys(path, extra)
if (keys2.size < keys1.size) {
val extra = keys1 - keys2
return JsonError.ObjectExtraKeys(path, extra)
}
}

// when using strict order mode, the order of elements in json matters, normally, we don't care
when (order) {
CompareOrder.Strict ->
expected.elements.entries.withIndex().zip(actual.elements.entries).forEach { (e, a) ->
if (a.key != e.value.key) return JsonError.NameOrderDiff(path, e.index, e.value.key, a.key)
val error = compare(path + a.key, e.value.value, a.value, mode, order)
val error = compare(path + a.key, e.value.value, a.value, mode, order, fieldComparison)
if (error != null) return error
}
CompareOrder.LenientAll,
CompareOrder.LenientProperties,
CompareOrder.Lenient ->
expected.elements.entries.forEach { (name, e) ->
val a = actual.elements[name] ?: return JsonError.ObjectMissingKeys(path, setOf(name))
val error = compare(path + name, e, a, mode, order)
val error = compare(path + name, e, a, mode, order, fieldComparison)
if (error != null) return error
}
}
Expand All @@ -141,6 +152,7 @@ internal fun compareArrays(
actual: JsonNode.ArrayNode,
mode: CompareMode,
order: CompareOrder,
fieldComparison: FieldComparison,
): JsonError? {

if (expected.elements.size != actual.elements.size)
Expand All @@ -151,7 +163,7 @@ internal fun compareArrays(
CompareOrder.Lenient,
CompareOrder.Strict -> {
expected.elements.withIndex().zip(actual.elements.withIndex()).forEach { (a, b) ->
val error = compare(path + "[${a.index}]", a.value, b.value, mode, order)
val error = compare(path + "[${a.index}]", a.value, b.value, mode, order, fieldComparison)
if (error != null) return error
}
}
Expand All @@ -172,7 +184,7 @@ internal fun compareArrays(
fun findMatchingIndex(element: JsonNode): Int? {
for (i in availableIndexes()) {
// Comparison with no error -> matching element
val isMatch = compare(path + "[$i]", element, expected.elements[i], mode, order) == null
val isMatch = compare(path + "[$i]", element, expected.elements[i], mode, order, fieldComparison) == null

if (isMatch) {
return i
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,22 @@ import io.kotest.matchers.MatcherResult
* regardless of order.
*
*/
fun equalJson(expected: JsonTree, mode: CompareMode, order: CompareOrder) = object : Matcher<JsonTree> {
fun equalJson(
expected: JsonTree,
mode: CompareMode,
order: CompareOrder,
fieldComparison: FieldComparison = FieldComparison.Exact,
) = object : Matcher<JsonTree> {
override fun test(value: JsonTree): MatcherResult {
val error = compare(
path = listOf(), expected = expected.root, actual = value.root, mode = mode, order = order
path = listOf(),
expected = expected.root,
actual = value.root,
mode = mode,
order = order,
fieldComparison
)?.asString()

return MatcherResult(
error == null,
{ "$error\n\nexpected:\n${expected.raw}\n\nactual:\n${value.raw}\n" },
Expand All @@ -34,6 +45,12 @@ infix fun String.shouldEqualJson(expected: String): Unit =
infix fun String.shouldNotEqualJson(expected: String): Unit =
this.shouldNotEqualJson(expected, CompareMode.Strict, CompareOrder.LenientProperties)

infix fun String.shouldEqualSpecifiedJson(expected: String) =
this.shouldEqualSpecifiedJson(expected, CompareMode.Strict)

infix fun String.shouldNotEqualSpecifiedJson(expected: String) =
this.shouldNotEqualSpecifiedJson(expected, CompareMode.Strict)

fun String.shouldEqualJson(expected: String, mode: CompareMode) =
shouldEqualJson(expected, mode, CompareOrder.LenientProperties)

Expand All @@ -45,3 +62,15 @@ fun String.shouldEqualJson(expected: String, order: CompareOrder) =

fun String.shouldNotEqualJson(expected: String, order: CompareOrder) =
shouldNotEqualJson(expected, CompareMode.Strict, order)

fun String.shouldEqualSpecifiedJson(expected: String, mode: CompareMode) =
shouldEqualSpecifiedJson(expected, mode, CompareOrder.Lenient)

fun String.shouldNotEqualSpecifiedJson(expected: String, mode: CompareMode) =
shouldNotEqualSpecifiedJson(expected, mode, CompareOrder.Lenient)

fun String.shouldEqualSpecifiedJson(expected: String, order: CompareOrder) =
shouldEqualSpecifiedJson(expected, CompareMode.Strict, order)

fun String.shouldNotEqualSpecifiedJson(expected: String, order: CompareOrder) =
shouldNotEqualSpecifiedJson(expected, CompareMode.Strict, order)

0 comments on commit 4ae2869

Please sign in to comment.