Skip to content

Commit

Permalink
comparing Maps, find keys similar to missing (#27) (#3866)
Browse files Browse the repository at this point in the history
When keys are data classes with several fields, it really helps to find
similarities between actual and expected keys, as shown in the section
"Possible matches for missing keys" of the following output, where the
matcher finds the most similar key:
```
val sweetGreenApple = Fruit("apple", "green", "sweet")
val sweetGreenPear = Fruit("pear", "green", "sweet")

            val e = shouldThrow<AssertionError> {
               mapOf(
                  sweetGreenApple to 1
               ) should containExactly(mapOf(sweetGreenPear to 1))
            }
            e.message shouldBe """
            |
            |Expected:
            |  mapOf(Fruit(name=apple, color=green, taste=sweet) to 1)
            |should be equal to:
            |  mapOf(Fruit(name=pear, color=green, taste=sweet) to 1)
            |but differs by:
            |  missing keys:
            |    Fruit(name=pear, color=green, taste=sweet)
            |  extra keys:
            |    Fruit(name=apple, color=green, taste=sweet)
            |
            |Possible matches for missing keys:
            |
            | expected: Fruit(name=pear, color=green, taste=sweet),
            |  but was: Fruit(name=apple, color=green, taste=sweet),
            |  distance: 0.67,
            |  fields:
            |  name expected: "pear", but was: "apple"
            |  color = "green"
            |  taste = "sweet"
              """.trimMargin()
```

---------

Co-authored-by: Emil Kantis <emil.kantis@protonmail.com>
  • Loading branch information
AlexCue987 and Kantis committed Feb 27, 2024
1 parent 968653b commit cd60ed6
Show file tree
Hide file tree
Showing 25 changed files with 692 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.string.Diff
import io.kotest.matchers.string.stringify
import io.kotest.similarity.possibleMatchesDescription

fun <K> haveKey(key: K): Matcher<Map<K, Any?>> = object : Matcher<Map<K, Any?>> {
override fun test(value: Map<K, Any?>) = MatcherResult(
Expand Down Expand Up @@ -101,6 +102,12 @@ class MapContainsMatcher<K, V>(
) : Matcher<Map<K, V>> {
override fun test(value: Map<K, V>): MatcherResult {
val diff = Diff.create(value, expected, ignoreExtraMapKeys = ignoreExtraKeys)
val unexpectedKeys = (value.keys - expected.keys)
val possibleMatches = unexpectedKeys.joinToString("\n") {
possibleMatchesDescription(expected.keys, it)
}
val possibleMatchesDescription = if(possibleMatches.isEmpty()) ""
else "\nPossible matches for missing keys:\n$possibleMatches"
val (expectMsg, negatedExpectMsg) = if (ignoreExtraKeys) {
"should contain all of" to "should not contain all of"
} else {
Expand Down Expand Up @@ -132,7 +139,7 @@ class MapContainsMatcher<K, V>(
""".trimMargin()
return MatcherResult(
diff.isEmpty(),
{ failureMsg },
{ failureMsg + possibleMatchesDescription },
{
negatedFailureMsg
})
Expand Down Expand Up @@ -187,3 +194,4 @@ class MapMatchesMatcher<K, V>(
)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,32 @@ class MapMatchersTest : WordSpec() {
|
""".trimMargin()
}
"test assertion that a map contains extra keys and find similarities" {
val e = shouldThrow<AssertionError> {
mapOf(
sweetGreenApple to 1
) should containExactly(mapOf(sweetGreenPear to 1))
}
e.message shouldBe """
|
|Expected:
| mapOf(Fruit(name=apple, color=green, taste=sweet) to 1)
|should be equal to:
| mapOf(Fruit(name=pear, color=green, taste=sweet) to 1)
|but differs by:
| missing keys:
| Fruit(name=pear, color=green, taste=sweet)
| extra keys:
| Fruit(name=apple, color=green, taste=sweet)
|
|Possible matches for missing keys:
|
| expected: Fruit(name=pear, color=green, taste=sweet),
| but was: Fruit(name=apple, color=green, taste=sweet),
| The following fields did not match:
| "name" expected: <"pear">, but was: <"apple">
""".trimMargin()
}
"test shouldNot assertion" {
val e = shouldThrow<AssertionError> {
val arrayList: List<Int> = arrayListOf(1)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.sksamuel.kotest.matchers.maps

data class Thing(
val color: String,
val shape: String,
)

val redCircle = Thing("red", "circle")
val blueCircle = Thing("blue", "circle")
val redTriangle = Thing("red", "triangle")
val blueTriangle = Thing("blue", "triangle")

data class OtherThing(
val color: String,
val shape: String,
)

val otherRedCircle = OtherThing("red", "circle")

data class ThingWithPrivateField(
val color: String,
private val shape: String,
)

val redCircleWithPrivateField = ThingWithPrivateField("red", "circle")

data class CountedName(
val name: String,
val count: Int
)

val oneApple = CountedName("apple", 1)
val twoApples = CountedName("apple", 2)
val oneOrange = CountedName("orange", 1)
val twoOranges = CountedName("orange", 2)
val threeLemons = CountedName("lemon", 3)

data class Fruit(
val name: String,
val color: String,
val taste: String
)

val sweetGreenApple = Fruit("apple", "green", "sweet")
val sweetRedApple = Fruit("apple", "red", "sweet")
val sweetGreenPear = Fruit("pear", "green", "sweet")
val sourYellowLemon = Fruit("lemon", "yellow", "sour")
val tartRedCherry = Fruit("cherry", "red", "tart")
val bitterPurplePlum = Fruit("plum", "purple", "bitter")

Original file line number Diff line number Diff line change
Expand Up @@ -3062,3 +3062,7 @@ public final class io/kotest/matchers/ShouldKt {
public static final fun shouldNotHave (Ljava/lang/Object;Lio/kotest/matchers/Matcher;)V
}

public final class io/kotest/similarity/PossibleMatchesKt {
public static final fun possibleMatchesDescription (Ljava/util/Set;Ljava/lang/Object;)Ljava/lang/String;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.kotest.similarity

expect fun<T> possibleMatchesDescription(expected: Set<T>, actual: T): String

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.kotest.similarity

actual fun<T> possibleMatchesDescription(expected: Set<T>, actual: T): String = ""

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.kotest.similarity

actual fun<T> possibleMatchesDescription(expected: Set<T>, actual: T): String = ""

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.kotest.similarity

import java.math.BigDecimal

internal fun findBestMatches(element: Any?, candidates: List<Any?>): List<IndexedComparisonResult> {
val comparisonResults = candidates.mapIndexed { index, candidate ->
IndexedComparisonResult(
index,
VanillaDistanceCalculator.compare("", candidate, element)
)
}

val (completeMatches, partialMatches) = comparisonResults.filter {
it.comparisonResult !is AtomicMismatch
}.partition { it.comparisonResult is Match }

if(completeMatches.isNotEmpty()) return completeMatches

return partialMatches.asSequence()
.mapNotNull {
when(it.comparisonResult) {
is MismatchByField -> IndexedMismatchByField(it.index, it.comparisonResult)
else -> null
}
}
.filter { it.comparisonResult.distance.distance > BigDecimal.ZERO }
.topWithTiesBy {
it.comparisonResult.distance.distance
}.map {
IndexedComparisonResult(it.index, it.comparisonResult)
}
}

internal data class IndexedComparisonResult(
val index: Int,
val comparisonResult: ComparisonResult
)

internal data class IndexedMismatchByField(
val index: Int,
val comparisonResult: MismatchByField
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.kotest.similarity

internal fun<T> closestMatches(expected: Set<T>, actual: T): List<PairComparison<T>> {
return expected.asSequence().mapNotNull { candidate ->
val comparisonResult = VanillaDistanceCalculator.compare("", candidate, actual)
if (comparisonResult is MismatchByField &&
comparisonResult.distance.distance > Distance.COMPLETE_MISMATCH_VALUE) {
PairComparison(actual, candidate, comparisonResult)
} else null
}.topWithTiesBy {
it.comparisonResult.distance.distance
}
}

internal data class PairComparison<T>(
val value: T,
val possibleMatch: T,
val comparisonResult: MismatchByField
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package io.kotest.similarity

import io.kotest.assertions.print.print
import io.kotest.similarity.Distance.Companion.CompleteMatch
import io.kotest.similarity.Distance.Companion.CompleteMismatch
import java.math.BigDecimal

internal sealed interface ComparisonResult {
val match: Boolean
fun description(): String
}

internal data class Match(
val field: String,
val value: Any?
): ComparisonResult {
val distance
get() = CompleteMatch
override fun description() = " $field = ${value.print().value}"
override val match: Boolean
get() = true
}

internal data class AtomicMismatch(
val field: String,
val expected: Any?,
val actual: Any?,
val distance: Distance = CompleteMismatch
): ComparisonResult {
override fun description() = " \"$field\" expected: <${expected.print().value}>, but was: <${actual.print().value}>"
override val match: Boolean
get() = false
}

internal data class MismatchByField(
val field: String,
val expected: Any,
val actual: Any,
val comparisonResults: List<ComparisonResult>,
val distance: Distance
): ComparisonResult {
override fun description() = """$field expected: $expected,
| but was: $actual,
| The following fields did not match:
|${comparisonResults.filter{ !it.match }.joinToString("\n ") { it.description() }}""".trimMargin()
override val match: Boolean
get() = comparisonResults.all { it.match }
}

internal fun possibleMatchDescription(possibleMatch: PossibleMatch): String = when(possibleMatch.comparisonResult) {
is Match -> "actual[${possibleMatch.actual.index}] == expected[${possibleMatch.matchInExpected.index}], is: ${possibleMatch.actual.element}"
is AtomicMismatch -> "actual[${possibleMatch.actual.index}] = ${possibleMatch.actual.element} is similar to\nexpected[${possibleMatch.matchInExpected.index}] = ${possibleMatch.matchInExpected.element}\n"
is MismatchByField -> possibleMismatchByFieldDescription(possibleMatch)
}

internal fun possibleMismatchByFieldDescription(possibleMatch: PossibleMatch): String {
val mismatchByField = (possibleMatch.comparisonResult as MismatchByField)
val header = "actual[${possibleMatch.actual.index}] = ${possibleMatch.actual.element} is similar to\nexpected[${possibleMatch.matchInExpected.index}] = ${possibleMatch.matchInExpected.element}\n"
val fields = mismatchByField.comparisonResults.joinToString("\n") {
comparisonResultDescription(it)
}
return "$header\n$fields"
}

internal fun comparisonResultDescription(comparisonResult: ComparisonResult): String = when(comparisonResult) {
is Match -> "\"${comparisonResult.field}\" = ${comparisonResult.value}"
is AtomicMismatch -> "\"${comparisonResult.field}\" expected: <${comparisonResult.expected}>,\n but was: <${comparisonResult.actual}>"
else -> "Unknown $comparisonResult"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.kotest.similarity

import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.KProperty1
import kotlin.reflect.jvm.isAccessible

internal class FieldsReader {
fun fieldsOf(instance: Any): List<FieldAndValue> {
require(instance::class.isData) {
"Data classes expected, was: ${instance::class.qualifiedName}"
}
val fields = getFields(instance)
return fields.asSequence()
.map {
it.getter.isAccessible = true
val value = it.getter.call(instance)
FieldAndValue(it.name, value)
}
.toList()
}

fun getFields(instance: Any): Collection<KProperty1<out Any, *>> {
val fields = instance::class.memberProperties
val klass = instance.javaClass.kotlin
val primaryConstructor = klass.primaryConstructor
val params = primaryConstructor!!.parameters
return params.map { param -> fields.first { field -> field.name == param.name} }
}
}

internal data class FieldAndValue(
val name: String,
val value: Any?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.kotest.similarity

import java.math.BigDecimal

internal interface IDistanceCalculator {
fun compare(field: String, expected: Any?, actual: Any?): ComparisonResult
}

internal data class Distance(
val distance: BigDecimal
) {
init {
require(distance in COMPLETE_MISMATCH_VALUE..COMPLETE_MATCH_VALUE) {
"Distance must be between 0 and 1, was: $distance"
}
}
companion object {
val COMPLETE_MISMATCH_VALUE: BigDecimal = BigDecimal.ZERO
val COMPLETE_MATCH_VALUE: BigDecimal = BigDecimal.ONE

val CompleteMatch = Distance(COMPLETE_MATCH_VALUE)
val CompleteMismatch = Distance(COMPLETE_MISMATCH_VALUE)
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.kotest.similarity

import java.math.BigDecimal
import java.math.MathContext

internal fun matchByFields(field: String, expected: Any, actual: Any): ComparisonResult {
require(expected::class == actual::class) {
"Expected and Actual should be of same type, were ${expected::class.qualifiedName} and ${actual::class.qualifiedName}"
}
val fieldsReader = FieldsReader()
val expectedFields = fieldsReader.fieldsOf(expected)
val actualFields = fieldsReader.fieldsOf(actual)
val comparisons = expectedFields.mapIndexed{
index, expectedField ->
val actualField = actualFields[index]
VanillaDistanceCalculator.compare(expectedField.name, expectedField.value, actualField.value)
}
val matches = comparisons.count { it is Match }
val distance = Distance(BigDecimal(matches)
.divide(BigDecimal(expectedFields.size), MathContext(2)))
return MismatchByField(field, expected, actual, comparisons, distance)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.kotest.similarity

internal data class IndexedElement(
val index: Int,
val element: Any?
)

internal data class PossibleMatch(
val matchInExpected: IndexedElement,
val actual: IndexedElement,
val comparisonResult: ComparisonResult
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.kotest.similarity

actual fun<T> possibleMatchesDescription(expected: Set<T>, actual: T): String {
val possibleMatches = closestMatches(expected, actual)
return if(possibleMatches.isEmpty()) ""
else {
"\n${possibleMatches.joinToString("\n\n"){it.comparisonResult.description()}}"
}
}

0 comments on commit cd60ed6

Please sign in to comment.