diff --git a/documentation/docs/assertions/json.md b/documentation/docs/assertions/json.md index 62f96c03953..d6c513b138e 100644 --- a/documentation/docs/assertions/json.md +++ b/documentation/docs/assertions/json.md @@ -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. @@ -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** diff --git a/documentation/docs/changelog.md b/documentation/docs/changelog.md index e93ad7a573c..be372a6b09a 100644 --- a/documentation/docs/changelog.md +++ b/documentation/docs/changelog.md @@ -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. @@ -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 diff --git a/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/JsonMatchers.kt b/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/JsonMatchers.kt index 8ca2447c80b..dc6dd227112 100644 --- a/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/JsonMatchers.kt +++ b/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/JsonMatchers.kt @@ -62,12 +62,22 @@ fun matchJson(expected: String?) = object : Matcher { */ 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 { diff --git a/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/compare.kt b/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/compare.kt index 6dac7c60b96..a8094fd314a 100644 --- a/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/compare.kt +++ b/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/compare.kt @@ -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. */ @@ -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) @@ -99,19 +107,22 @@ 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 @@ -119,7 +130,7 @@ internal fun compareObjects( 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, @@ -127,7 +138,7 @@ internal fun compareObjects( 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 } } @@ -141,6 +152,7 @@ internal fun compareArrays( actual: JsonNode.ArrayNode, mode: CompareMode, order: CompareOrder, + fieldComparison: FieldComparison, ): JsonError? { if (expected.elements.size != actual.elements.size) @@ -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 } } @@ -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 diff --git a/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/matchers.kt b/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/matchers.kt index 974a1e7f67f..aac0adc03f7 100644 --- a/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/matchers.kt +++ b/kotest-assertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/matchers.kt @@ -14,11 +14,22 @@ import io.kotest.matchers.MatcherResult * regardless of order. * */ -fun equalJson(expected: JsonTree, mode: CompareMode, order: CompareOrder) = object : Matcher { +fun equalJson( + expected: JsonTree, + mode: CompareMode, + order: CompareOrder, + fieldComparison: FieldComparison = FieldComparison.Exact, +) = object : Matcher { 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" }, @@ -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) @@ -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) diff --git a/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/EqualIgnoringUnknownTest.kt b/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/EqualIgnoringUnknownTest.kt new file mode 100644 index 00000000000..a6a506b83b6 --- /dev/null +++ b/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/EqualIgnoringUnknownTest.kt @@ -0,0 +1,212 @@ +package com.sksamuel.kotest.tests.json + +import io.kotest.assertions.json.shouldEqualSpecifiedJson +import io.kotest.assertions.shouldFail +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.throwable.shouldHaveMessage +import io.kotest.property.Arb +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.az +import io.kotest.property.arbitrary.string +import io.kotest.property.checkAll + +class EqualIgnoringUnknownTest : FunSpec( + { + test("extra field in actual - passes") { + checkAll(Arb.string(1..10, Codepoint.az())) { string -> + val a = """ { "a" : "$string", "b": "bar" } """ + val b = """ { "a" : "$string" }""" + + a shouldEqualSpecifiedJson b + } + } + + test("expected field has different value - should fail") { + val a = """ { "a" : "foo", "b" : "bar" } """ + val b = """ { "a" : "foo", "b" : "baz" } """ + + a shouldEqualSpecifiedJson a + + shouldFail { + a shouldEqualSpecifiedJson b + }.shouldHaveMessage( + """At 'b' expected 'baz' but was 'bar' + +expected: +{ + "a": "foo", + "b": "baz" +} + +actual: +{ + "a": "foo", + "b": "bar" +}""" + ) + } + + test("actual missing field") { + val a = """ { "a" : "foo" } """ + val b = """ { "a" : "foo", "b": "bar" } """ + + shouldFail { + a shouldEqualSpecifiedJson b + }.shouldHaveMessage( + """The top level object was missing expected field(s) [b] + +expected: +{ + "a": "foo", + "b": "bar" +} + +actual: +{ + "a": "foo" +}""" + ) + } + + context("Nested object") { + test("extra field is ok") { + checkAll(Arb.string(1..10, Codepoint.az())) { string -> + val a = """ { "wrapper": { "a" : "$string", "b": "bar" } }""" + val b = """ { "wrapper": { "a" : "$string" } }""" + + a shouldEqualSpecifiedJson b + } + } + + test("nested expected value differs") { + val a = """ { "wrapper": { "a" : "foo", "b": "bar" } }""" + val b = """ { "wrapper": { "a" : "foo", "b": "baz" } }""" + + a shouldEqualSpecifiedJson a + + shouldFail { + a shouldEqualSpecifiedJson b + }.shouldHaveMessage( + """At 'wrapper.b' expected 'baz' but was 'bar' + +expected: +{ + "wrapper": { + "a": "foo", + "b": "baz" + } +} + +actual: +{ + "wrapper": { + "a": "foo", + "b": "bar" + } +}""" + ) + } + + test("actual missing field") { + val a = """ { "wrapper": { "a" : "foo" } } """ + val b = """ { "wrapper": { "a" : "foo", "b": "bar" } } """ + + shouldFail { + a shouldEqualSpecifiedJson b + }.shouldHaveMessage( + """At 'wrapper' object was missing expected field(s) [b] + +expected: +{ + "wrapper": { + "a": "foo", + "b": "bar" + } +} + +actual: +{ + "wrapper": { + "a": "foo" + } +}""" + ) + } + + } + + context("Arrays") { + test("extra field is ok") { + checkAll(Arb.string(1..10, Codepoint.az())) { string -> + val a = """ { "wrapper": [{ "a" : "$string", "b": "bar" }] }""" + val b = """ { "wrapper": [{ "a" : "$string" }] }""" + + a shouldEqualSpecifiedJson b + } + } + + test("nested expected value differs") { + val a = """ { "wrapper": [{ "a" : "foo", "b": "bar" }] }""" + val b = """ { "wrapper": [{ "a" : "foo", "b": "baz" }] }""" + + a shouldEqualSpecifiedJson a + + shouldFail { + a shouldEqualSpecifiedJson b + }.shouldHaveMessage( + """At 'wrapper.[0].b' expected 'baz' but was 'bar' + +expected: +{ + "wrapper": [ + { + "a": "foo", + "b": "baz" + } + ] +} + +actual: +{ + "wrapper": [ + { + "a": "foo", + "b": "bar" + } + ] +}""" + ) + } + + test("actual missing field") { + val a = """ { "wrapper": [ { "a" : "foo" } ] } """ + val b = """ { "wrapper": [ { "a" : "foo", "b": "bar" } ] } """ + + shouldFail { + a shouldEqualSpecifiedJson b + }.shouldHaveMessage( + """At 'wrapper.[0]' object was missing expected field(s) [b] + +expected: +{ + "wrapper": [ + { + "a": "foo", + "b": "bar" + } + ] +} + +actual: +{ + "wrapper": [ + { + "a": "foo" + } + ] +}""" + ) + } + } + } +)