Skip to content

Commit

Permalink
Enhance Json matchers #1984 (#1985)
Browse files Browse the repository at this point in the history
* Enhance Json matchers #1984

* Added more tests

* Expand JSON matchers to support JS #1986

* Added CompareOrder to json matchers #1984
  • Loading branch information
sksamuel committed Jan 12, 2021
1 parent e782417 commit f456f06
Show file tree
Hide file tree
Showing 26 changed files with 3,545 additions and 191 deletions.
3 changes: 2 additions & 1 deletion buildSrc/src/main/kotlin/Libs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ object Libs {
}

object Jackson {
private const val version = "2.10.5"
private const val version = "2.11.3"
const val kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:$version"
const val databind = "com.fasterxml.jackson.core:jackson-databind:$version"
}

object Koin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@ import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.neverNullMatcher






fun <T> existInOrder(vararg ps: (T) -> Boolean): Matcher<Collection<T>?> = existInOrder(ps.asList())

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ infix fun Throwable.shouldNotHaveMessage(message: String) = this shouldNot haveM

fun haveMessage(message: String) = object : Matcher<Throwable> {
override fun test(value: Throwable) = MatcherResult(
value.message == message,
"Throwable should have message ${message.show().value}, but instead got ${value.message.show().value}",
"Throwable should not have message ${message.show().value}"
value.message?.trim() == message.trim(),
"Throwable should have message:\n${message.trim().show().value}\n\nActual was:\n${value.message?.trim().show().value}\n",
"Throwable should not have message:\n${message.trim().show().value}"
)
}

Expand Down
29 changes: 27 additions & 2 deletions kotest-assertions/kotest-assertions-json/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ kotlin {
}
}
}
js {
browser()
nodejs()
}
}

targets.all {
Expand All @@ -31,10 +35,28 @@ kotlin {

sourceSets {

val jvmMain by getting {
val commonMain by getting {
dependencies {
implementation(project(Projects.AssertionsShared))
implementation(kotlin("reflect"))
implementation(Libs.Jackson.databind)
implementation(Libs.Jackson.kotlin)
implementation(Libs.Jayway.jsonpath)
}
}

val commonTest by getting {
dependencies {
implementation(project(Projects.AssertionsCore))
implementation(project(Projects.Api))
implementation(project(Projects.Engine))
implementation(project(Projects.Property))
}
}

val jvmMain by getting {
dependencies {
implementation(Libs.Jackson.databind)
implementation(Libs.Jackson.kotlin)
implementation(Libs.Jayway.jsonpath)
}
Expand Down Expand Up @@ -62,7 +84,10 @@ tasks.named<Test>("jvmTest") {
testLogging {
showExceptions = true
showStandardStreams = true
events = setOf(org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED, org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED)
events = setOf(
org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED,
org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED
)
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
Expand Down
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)
}
}
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'"
}
}
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)
Loading

0 comments on commit f456f06

Please sign in to comment.