-
Notifications
You must be signed in to change notification settings - Fork 643
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Enhance Json matchers #1984 * Added more tests * Expand JSON matchers to support JS #1986 * Added CompareOrder to json matchers #1984
- Loading branch information
Showing
26 changed files
with
3,545 additions
and
191 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
205 changes: 205 additions & 0 deletions
205
...ertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/compare.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,205 @@ | ||
@file:Suppress("unused") | ||
|
||
package io.kotest.assertions.json | ||
|
||
import kotlin.math.abs | ||
|
||
enum class CompareMode { | ||
Strict, Lenient | ||
} | ||
|
||
enum class CompareOrder { | ||
Strict, Lenient | ||
} | ||
|
||
/** | ||
* Compares two json trees, returning a detailed error message if they differ. | ||
*/ | ||
fun compare(expected: JsonNode, actual: JsonNode, mode: CompareMode, order: CompareOrder) = | ||
compare(emptyList(), expected, actual, mode, order) | ||
|
||
fun compare( | ||
path: List<String>, | ||
expected: JsonNode, | ||
actual: JsonNode, | ||
mode: CompareMode, | ||
order: CompareOrder | ||
): JsonError? { | ||
return when (expected) { | ||
is JsonNode.ObjectNode -> when (actual) { | ||
is JsonNode.ObjectNode -> compareObjects(path, expected, actual, mode, order) | ||
else -> JsonError.ExpectedObject(path, actual) | ||
} | ||
is JsonNode.ArrayNode -> when (actual) { | ||
is JsonNode.ArrayNode -> compareArrays(path, expected, actual, mode, order) | ||
else -> JsonError.ExpectedArray(path, actual) | ||
} | ||
is JsonNode.BooleanNode -> compareBoolean(path, expected, actual, mode) | ||
is JsonNode.StringNode -> compareString(path, expected, actual, mode) | ||
is JsonNode.LongNode -> compareLong(path, expected, actual, mode) | ||
is JsonNode.DoubleNode -> compareDouble(path, expected, actual, mode) | ||
JsonNode.NullNode -> compareNull(path, actual) | ||
} | ||
} | ||
|
||
fun compareObjects( | ||
path: List<String>, | ||
expected: JsonNode.ObjectNode, | ||
actual: JsonNode.ObjectNode, | ||
mode: CompareMode, | ||
order: CompareOrder, | ||
): JsonError? { | ||
|
||
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 (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) | ||
if (error != null) return error | ||
} | ||
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) | ||
if (error != null) return error | ||
} | ||
} | ||
|
||
return null | ||
} | ||
|
||
fun compareArrays( | ||
path: List<String>, | ||
expected: JsonNode.ArrayNode, | ||
actual: JsonNode.ArrayNode, | ||
mode: CompareMode, | ||
order: CompareOrder, | ||
): JsonError? { | ||
|
||
if (expected.elements.size != actual.elements.size) | ||
return JsonError.UnequalArrayLength(path, expected.elements.size, actual.elements.size) | ||
|
||
expected.elements.withIndex().zip(actual.elements.withIndex()).forEach { (a, b) -> | ||
val error = compare(path + "[${a.index}]", a.value, b.value, mode, order) | ||
if (error != null) return error | ||
} | ||
|
||
return null | ||
} | ||
|
||
/** | ||
* When comparing a string, if the [mode] is [CompareMode.Lenient] we can convert the actual node to a string. | ||
*/ | ||
fun compareString(path: List<String>, expected: JsonNode.StringNode, actual: JsonNode, mode: CompareMode): JsonError? { | ||
return when { | ||
actual is JsonNode.StringNode -> compareStrings(path, expected.value, actual.value) | ||
mode == CompareMode.Lenient -> when (actual) { | ||
is JsonNode.BooleanNode -> compareStrings(path, expected.value, actual.value.toString()) | ||
is JsonNode.DoubleNode -> compareStrings(path, expected.value, actual.value.toString()) | ||
is JsonNode.LongNode -> compareStrings(path, expected.value, actual.value.toString()) | ||
else -> JsonError.IncompatibleTypes(path, expected, actual) | ||
} | ||
else -> JsonError.IncompatibleTypes(path, expected, actual) | ||
} | ||
} | ||
|
||
fun compareStrings(path: List<String>, expected: String, actual: String): JsonError? { | ||
return when (expected) { | ||
actual -> null | ||
else -> JsonError.UnequalStrings(path, expected, actual) | ||
} | ||
} | ||
|
||
/** | ||
* When comparing a boolean, if the [mode] is [CompareMode.Lenient] and the actual node is a text | ||
* node with "true" or "false", then we convert. | ||
*/ | ||
fun compareBoolean( | ||
path: List<String>, | ||
expected: JsonNode.BooleanNode, | ||
actual: JsonNode, | ||
mode: CompareMode | ||
): JsonError? { | ||
return when { | ||
actual is JsonNode.BooleanNode -> compareBooleans(path, expected.value, actual.value) | ||
mode == CompareMode.Lenient && actual is JsonNode.StringNode -> when (actual.value) { | ||
"true" -> compareBooleans(path, expected.value, true) | ||
"false" -> compareBooleans(path, expected.value, false) | ||
else -> JsonError.UnequalValues(path, expected, actual) | ||
} | ||
else -> JsonError.IncompatibleTypes(path, expected, actual) | ||
} | ||
} | ||
|
||
fun compareBooleans(path: List<String>, expected: Boolean, actual: Boolean): JsonError? { | ||
return when (expected) { | ||
actual -> null | ||
else -> JsonError.UnequalBooleans(path, expected, actual) | ||
} | ||
} | ||
|
||
/** | ||
* When comparing a boolean, if the [mode] is [CompareMode.Lenient] and the actual node is a text | ||
* node with "true" or "false", then we convert. | ||
*/ | ||
fun compareLong(path: List<String>, expected: JsonNode.LongNode, actual: JsonNode, mode: CompareMode): JsonError? { | ||
return when { | ||
actual is JsonNode.LongNode -> compareLongs(path, expected.value, actual.value) | ||
mode == CompareMode.Lenient && actual is JsonNode.StringNode -> when (val l = actual.value.toLongOrNull()) { | ||
null -> JsonError.IncompatibleTypes(path, expected, actual) | ||
else -> compareLongs(path, expected.value, l) | ||
} | ||
else -> JsonError.IncompatibleTypes(path, expected, actual) | ||
} | ||
} | ||
|
||
fun compareLongs(path: List<String>, expected: Long, actual: Long): JsonError? { | ||
return when (expected) { | ||
actual -> null | ||
else -> JsonError.UnequalValues(path, expected, actual) | ||
} | ||
} | ||
|
||
/** | ||
* When comparing a boolean, if the [mode] is [CompareMode.Lenient] and the actual node is a text | ||
* node with "true" or "false", then we convert. | ||
*/ | ||
fun compareDouble(path: List<String>, expected: JsonNode.DoubleNode, actual: JsonNode, mode: CompareMode): JsonError? { | ||
return when { | ||
actual is JsonNode.DoubleNode -> compareDoubles(path, expected.value, actual.value) | ||
actual is JsonNode.LongNode -> compareDoubles(path, expected.value, actual.value.toDouble()) | ||
mode == CompareMode.Lenient && actual is JsonNode.StringNode -> when (val d = actual.value.toDoubleOrNull()) { | ||
null -> JsonError.IncompatibleTypes(path, expected, actual) | ||
else -> compareDoubles(path, expected.value, d) | ||
} | ||
else -> JsonError.IncompatibleTypes(path, expected, actual) | ||
} | ||
} | ||
|
||
fun compareDoubles(path: List<String>, expected: Double, actual: Double): JsonError? { | ||
return when { | ||
abs(expected - actual) <= Double.MIN_VALUE -> null | ||
else -> JsonError.UnequalValues(path, expected, actual) | ||
} | ||
} | ||
|
||
fun compareNull(path: List<String>, b: JsonNode): JsonError? { | ||
return when (b) { | ||
is JsonNode.NullNode -> null | ||
else -> JsonError.ExpectedNull(path, b) | ||
} | ||
} |
46 changes: 46 additions & 0 deletions
46
...sertions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/errors.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
package io.kotest.assertions.json | ||
|
||
sealed class JsonError { | ||
|
||
abstract val path: List<String> | ||
|
||
data class UnequalArrayLength( | ||
override val path: List<String>, | ||
val expected: Int, | ||
val actual: Int | ||
) : JsonError() | ||
|
||
data class ObjectMissingKeys(override val path: List<String>, val missing: Set<String>) : JsonError() | ||
data class ObjectExtraKeys(override val path: List<String>, val extra: Set<String>) : JsonError() | ||
data class ExpectedObject(override val path: List<String>, val b: JsonNode) : JsonError() | ||
data class ExpectedArray(override val path: List<String>, val b: JsonNode) : JsonError() | ||
data class UnequalStrings(override val path: List<String>, val a: String, val b: String) : JsonError() | ||
data class UnequalBooleans(override val path: List<String>, val a: Boolean, val b: Boolean) : JsonError() | ||
data class UnequalValues(override val path: List<String>, val a: Any, val b: Any) : JsonError() | ||
data class IncompatibleTypes(override val path: List<String>, val a: JsonNode, val b: JsonNode) : JsonError() | ||
data class ExpectedNull(override val path: List<String>, val b: JsonNode) : JsonError() | ||
|
||
data class NameOrderDiff( | ||
override val path: List<String>, | ||
val index: Int, | ||
val expected: String, | ||
val actual: String | ||
) : JsonError() | ||
} | ||
|
||
fun JsonError.asString(): String { | ||
val dotpath = if (path.isEmpty()) "The top level" else "At '" + path.joinToString(".") + "'" | ||
return when (this) { | ||
is JsonError.UnequalArrayLength -> "$dotpath expected array length ${this.expected} but was ${this.actual}" | ||
is JsonError.ObjectMissingKeys -> "$dotpath object was missing expected field(s) [${missing.joinToString(",")}]" | ||
is JsonError.ObjectExtraKeys -> "$dotpath object has extra field(s) [${extra.joinToString(",")}]" | ||
is JsonError.ExpectedObject -> "$dotpath expected object type but was ${b.type()}" | ||
is JsonError.ExpectedArray -> "$dotpath expected array type but was ${b.type()}" | ||
is JsonError.UnequalStrings -> "$dotpath expected '$a' but was '$b'" | ||
is JsonError.UnequalBooleans -> "$dotpath expected $a but was $b" | ||
is JsonError.UnequalValues -> "$dotpath expected $a but was $b" | ||
is JsonError.IncompatibleTypes -> "$dotpath expected ${a.type()} but was ${b.type()}" | ||
is JsonError.ExpectedNull -> "$dotpath expected null but was ${b.type()}" | ||
is JsonError.NameOrderDiff -> "$dotpath object expected field $index to be '$expected' but was '$actual'" | ||
} | ||
} |
56 changes: 56 additions & 0 deletions
56
...rtions/kotest-assertions-json/src/commonMain/kotlin/io/kotest/assertions/json/matchers.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
package io.kotest.assertions.json | ||
|
||
import io.kotest.matchers.Matcher | ||
import io.kotest.matchers.MatcherResult | ||
|
||
/** | ||
* Returns a [Matcher] that verifies json trees are equal. | ||
* | ||
* This common matcher requires json in kotest's [JsonNode] abstraction. | ||
* | ||
* The jvm module provides wrappers to convert from Jackson to this format. | ||
* | ||
* This matcher will consider two json strings matched if they have the same key-values pairs, | ||
* regardless of order. | ||
* | ||
*/ | ||
fun equalJson(expected: JsonTree, mode: CompareMode, order: CompareOrder) = object : Matcher<JsonTree> { | ||
override fun test(value: JsonTree): MatcherResult { | ||
val error = compare(expected.root, value.root, mode, order)?.asString() | ||
return MatcherResult( | ||
error == null, | ||
"$error\n\nexpected:\n${expected.raw}\n\nactual:\n${value.raw}\n", | ||
"Expected values to not match ${expected.raw}", | ||
) | ||
} | ||
} | ||
|
||
data class JsonTree(val root: JsonNode, val raw: String) | ||
|
||
infix fun String.shouldEqualJson(expected: String): Unit = | ||
this.shouldEqualJson(expected, CompareMode.Strict, CompareOrder.Lenient) | ||
|
||
infix fun String.shouldNotEqualJson(expected: String): Unit = | ||
this.shouldNotEqualJson(expected, CompareMode.Strict, CompareOrder.Lenient) | ||
|
||
fun String.shouldEqualJson(expected: String, mode: CompareMode) = | ||
shouldEqualJson(expected, mode, CompareOrder.Lenient) | ||
|
||
fun String.shouldNotEqualJson(expected: String, mode: CompareMode) = | ||
shouldNotEqualJson(expected, mode, CompareOrder.Lenient) | ||
|
||
fun String.shouldEqualJson(expected: String, order: CompareOrder) = | ||
shouldEqualJson(expected, CompareMode.Strict, order) | ||
|
||
fun String.shouldNotEqualJson(expected: String, order: CompareOrder) = | ||
shouldNotEqualJson(expected, CompareMode.Strict, order) | ||
|
||
/** | ||
* Verifies that the [expected] string is valid json, and that it matches this string. | ||
* | ||
* This matcher will consider two json strings matched if they have the same key-values pairs, | ||
* regardless of order. | ||
* | ||
*/ | ||
expect fun String.shouldEqualJson(expected: String, mode: CompareMode, order: CompareOrder) | ||
expect fun String.shouldNotEqualJson(expected: String, mode: CompareMode, order: CompareOrder) |
Oops, something went wrong.