Skip to content

Commit 68fec75

Browse files
authored
[MERGE FOR 5.2] Feature/add equals verifier concept (kotest#2795)
* add base comparators * convert contain methods using comparators * fix tests * add reflection comparison * add map equals * add map equality * refactor object equality stuff * add string diff * edit regex * add number equality * fix iterable equality * add iterable equality * push * simplify PR by removing extra equality checks * add unit test for ignoring fields * add unit tests * add collections matcher * fix failing test * rename interface and use companion object as factory * rename missing classes * fix failing tests
1 parent ae96301 commit 68fec75

File tree

12 files changed

+488
-59
lines changed

12 files changed

+488
-59
lines changed
Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,44 @@
11
package io.kotest.matchers.collections
22

33
import io.kotest.assertions.print.print
4+
import io.kotest.equals.Equality
45
import io.kotest.matchers.Matcher
56
import io.kotest.matchers.MatcherResult
67
import io.kotest.matchers.should
78
import io.kotest.matchers.shouldNot
89

9-
infix fun <T> Iterable<T>.shouldNotContain(t: T): Iterable<T> {
10-
toList().shouldNotContain(t)
11-
return this
12-
}
13-
14-
infix fun <T> Array<T>.shouldNotContain(t: T): Array<T> {
15-
asList().shouldNotContain(t)
16-
return this
17-
}
10+
// Infix
11+
infix fun <T> Iterable<T>.shouldNotContain(t: T): Iterable<T> = shouldNotContain(t, Equality.default())
12+
infix fun <T> Array<T>.shouldNotContain(t: T): Array<T> = shouldNotContain(t, Equality.default())
13+
infix fun <T> Iterable<T>.shouldContain(t: T): Iterable<T> = shouldContain(t, Equality.default())
14+
infix fun <T> Array<T>.shouldContain(t: T): Array<T> = shouldContain(t, Equality.default())
1815

19-
infix fun <T, C : Collection<T>> C.shouldNotContain(t: T): C {
20-
this shouldNot contain(t)
21-
return this
16+
// Should not
17+
fun <T> Iterable<T>.shouldNotContain(t: T, comparator: Equality<T>): Iterable<T> = apply {
18+
toList() shouldNot contain(t, comparator)
2219
}
2320

24-
infix fun <T> Iterable<T>.shouldContain(t: T): Iterable<T> {
25-
toList().shouldContain(t)
26-
return this
21+
fun <T> Array<T>.shouldNotContain(t: T, comparator: Equality<T>): Array<T> = apply {
22+
asList().shouldNotContain(t, comparator)
2723
}
2824

29-
infix fun <T> Array<T>.shouldContain(t: T): Array<T> {
30-
asList().shouldContain(t)
31-
return this
25+
// Should
26+
fun <T> Iterable<T>.shouldContain(t: T, comparator: Equality<T>): Iterable<T> = apply {
27+
toList() should contain(t, comparator)
3228
}
3329

34-
infix fun <T, C : Collection<T>> C.shouldContain(t: T): C {
35-
this should contain(t)
36-
return this
30+
fun <T> Array<T>.shouldContain(t: T, comparator: Equality<T>): Array<T> = apply {
31+
asList().shouldContain(t, comparator)
3732
}
3833

39-
fun <T, C : Collection<T>> contain(t: T) = object : Matcher<C> {
34+
// Matcher
35+
fun <T, C : Collection<T>> contain(t: T, verifier: Equality<T> = Equality.default()) = object : Matcher<C> {
4036
override fun test(value: C) = MatcherResult(
41-
value.contains(t),
42-
{ "Collection should contain element ${t.print().value}; listing some elements ${value.take(5)}" },
43-
{ "Collection should not contain element ${t.print().value}" }
37+
value.any { verifier.verify(it, t).areEqual() },
38+
{
39+
"Collection should contain element ${t.print().value} based on ${verifier.name()}; " +
40+
"listing some elements ${value.take(5)}"
41+
},
42+
{ "Collection should not contain element ${t.print().value} based on ${verifier.name()}" }
4443
)
4544
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.kotest.equals
2+
3+
import io.kotest.matchers.equality.beEqualToIgnoringFields
4+
import kotlin.reflect.KProperty
5+
6+
class ReflectionIgnoringFieldsEquality<T : Any>(
7+
private val property: KProperty<*>,
8+
private val others: Array<out KProperty<*>>,
9+
private val ignorePrivateFields: Boolean = true,
10+
) : Equality<T> {
11+
override fun name(): String {
12+
val plural = if (others.isNotEmpty()) "s" else ""
13+
val ignoringPrivate = if (ignorePrivateFields) "ignoring" else "including"
14+
return "reflection equality ignoring field$plural ${(listOf(property) + others).map { it.name }} and $ignoringPrivate private fields"
15+
}
16+
17+
fun includingPrivateFields(): ReflectionIgnoringFieldsEquality<T> {
18+
return withIgnorePrivateFields(false)
19+
}
20+
21+
fun ignoringPrivateFields(): ReflectionIgnoringFieldsEquality<T> {
22+
return withIgnorePrivateFields(true)
23+
}
24+
25+
private fun withIgnorePrivateFields(value: Boolean): ReflectionIgnoringFieldsEquality<T> {
26+
return ReflectionIgnoringFieldsEquality(
27+
property = property,
28+
others = others,
29+
ignorePrivateFields = value,
30+
)
31+
}
32+
33+
override fun verify(actual: T, expected: T): EqualityResult {
34+
val result = beEqualToIgnoringFields(expected, ignorePrivateFields, property, *others).test(actual)
35+
if(result.passed()) return EqualityResult.equal(actual, expected, this)
36+
return EqualityResult.notEqual(actual, expected, this).withDetails { result.failureMessage() }
37+
}
38+
39+
override fun toString(): String = name()
40+
}
41+
42+
fun <T : Any> Equality.Companion.byReflectionIgnoringFields(
43+
property: KProperty<*>,
44+
vararg others: KProperty<*>,
45+
ignorePrivateFields: Boolean = true,
46+
) = ReflectionIgnoringFieldsEquality<T>(
47+
property = property,
48+
others = others,
49+
ignorePrivateFields = ignorePrivateFields
50+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package io.kotest.equals
2+
3+
import io.kotest.matchers.equality.beEqualToUsingFields
4+
import kotlin.reflect.KProperty
5+
6+
class ReflectionUsingFieldsEquality<T : Any>(
7+
private val fields: Array<out KProperty<*>>
8+
) : Equality<T> {
9+
override fun name(): String {
10+
return "reflection equality using fields ${fields.map { it.name }}"
11+
}
12+
13+
override fun verify(actual: T, expected: T): EqualityResult {
14+
val result = beEqualToUsingFields(expected, *fields).test(actual)
15+
if (result.passed()) return EqualityResult.equal(actual, expected, this)
16+
return EqualityResult.notEqual(actual, expected, this).withDetails { result.failureMessage() }
17+
}
18+
19+
override fun toString(): String = name()
20+
}
21+
22+
fun <T : Any> Equality.Companion.byReflectionUsingFields(vararg fields: KProperty<*>) =
23+
ReflectionUsingFieldsEquality<T>(fields)

kotest-assertions/kotest-assertions-core/src/jvmMain/kotlin/io/kotest/matchers/equality/reflection.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,8 @@ fun <T : Any> beEqualToIgnoringFields(
294294
return MatcherResult(
295295
failed.isEmpty(),
296296
{ "$value should be equal to $other ignoring fields $fieldsString; Failed for $failed" },
297-
{
298-
"$value should not be equal to $other ignoring fields $fieldsString"
299-
})
297+
{ "$value should not be equal to $other ignoring fields $fieldsString" }
298+
)
300299
}
301300
}
302301

kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/matchers/collections/CollectionMatchersTest.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import io.kotest.assertions.shouldFail
44
import io.kotest.assertions.throwables.shouldNotThrow
55
import io.kotest.assertions.throwables.shouldThrow
66
import io.kotest.core.spec.style.WordSpec
7+
import io.kotest.equals.Equality
8+
import io.kotest.equals.types.byObjectEquality
79
import io.kotest.matchers.collections.atLeastSize
810
import io.kotest.matchers.collections.atMostSize
911
import io.kotest.matchers.collections.beLargerThan
@@ -300,13 +302,31 @@ class CollectionMatchersTest : WordSpec() {
300302
val col = listOf(1, 2, 3)
301303

302304
col should contain(2)
305+
col should contain(2.0) // uses strict num equality = false
303306

304307
shouldThrow<AssertionError> {
305308
col should contain(4)
306-
}.shouldHaveMessage("Collection should contain element 4; listing some elements [1, 2, 3]")
309+
}.shouldHaveMessage("Collection should contain element 4 based on object equality; listing some elements [1, 2, 3]")
307310
}
308311
}
309312

313+
"should contain element based on a custom equality object" should {
314+
"test that a collection contains an element" {
315+
val col = listOf(1, 2, 3.0)
316+
317+
318+
val verifier = Equality.byObjectEquality<Number>(strictNumberEquality = true)
319+
320+
col should contain(2, verifier)
321+
col should contain(3.0, verifier)
322+
323+
shouldThrow<AssertionError> {
324+
col should contain(3, verifier)
325+
}.shouldHaveMessage("Collection should contain element 3 based on object equality; listing some elements [1, 2, 3.0]")
326+
}
327+
}
328+
329+
310330
"shouldBeLargerThan" should {
311331
"test that a collection is larger than another collection" {
312332
val col1 = listOf(1, 2, 3)

kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/matchers/collections/ShouldContainTest.kt

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,55 @@ package com.sksamuel.kotest.matchers.collections
22

33
import io.kotest.assertions.throwables.shouldThrow
44
import io.kotest.core.spec.style.WordSpec
5+
import io.kotest.equals.Equality
6+
import io.kotest.equals.types.byObjectEquality
57
import io.kotest.matchers.collections.contain
68
import io.kotest.matchers.collections.shouldContain
79
import io.kotest.matchers.should
810
import io.kotest.matchers.throwable.shouldHaveMessage
911

10-
class ShouldContainTest : WordSpec() {
11-
12-
init {
13-
14-
"contain" should {
15-
"test that a collection contains element x" {
16-
val col = listOf(1, 2, 3)
17-
shouldThrow<AssertionError> {
18-
col should contain(4)
19-
}
20-
shouldThrow<AssertionError> {
21-
col.shouldContain(4)
22-
}
23-
col should contain(2)
24-
}
25-
"support infix shouldContain" {
26-
val col = listOf(1, 2, 3)
27-
col shouldContain (2)
12+
class ShouldContainTest : WordSpec({
13+
"contain" should {
14+
"test that a collection contains element x" {
15+
val col = listOf(1, 2, 3)
16+
shouldThrow<AssertionError> {
17+
col should contain(4)
2818
}
29-
"support type inference for subtypes of collection" {
30-
val tests = listOf(
31-
TestSealed.Test1("test1"),
32-
TestSealed.Test2(2)
33-
)
34-
tests should contain(TestSealed.Test1("test1"))
35-
tests.shouldContain(TestSealed.Test2(2))
19+
shouldThrow<AssertionError> {
20+
col.shouldContain(4)
3621
}
22+
col should contain(2)
23+
col should contain(2.0)
24+
}
25+
26+
"test that a collection contains element with a custom verifier" {
27+
val col = listOf(1, 2, 3)
28+
val verifier = Equality.byObjectEquality<Number>(strictNumberEquality = true)
3729

38-
"print errors unambiguously" {
39-
shouldThrow<AssertionError> {
40-
listOf<Any>(1, 2).shouldContain(listOf<Any>(1L, 2L))
41-
}.shouldHaveMessage("Collection should contain element [1L, 2L]; listing some elements [1, 2]")
30+
shouldThrow<AssertionError> {
31+
col.shouldContain(2.0, verifier)
4232
}
33+
col should contain(2, verifier)
34+
}
35+
36+
"support infix shouldContain" {
37+
val col = listOf(1, 2, 3)
38+
col shouldContain (2)
39+
}
40+
41+
"support type inference for subtypes of collection" {
42+
val tests = listOf(
43+
TestSealed.Test1("test1"),
44+
TestSealed.Test2(2)
45+
)
46+
tests should contain(TestSealed.Test1("test1"))
47+
tests.shouldContain(TestSealed.Test2(2))
48+
}
49+
50+
"print errors unambiguously" {
51+
shouldThrow<AssertionError> {
52+
listOf<Any>(1, 2).shouldContain(listOf<Any>(1L, 2L))
53+
}.shouldHaveMessage("Collection should contain element [1L, 2L] based on object equality; listing some elements [1, 2]")
4354
}
4455
}
45-
}
56+
})

kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/eq/IterableEq.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import io.kotest.assertions.Expected
55
import io.kotest.assertions.failure
66
import io.kotest.assertions.print.Printed
77
import io.kotest.assertions.print.print
8+
import kotlinx.coroutines.internal.LockFreeLinkedListHead
89

910
object IterableEq : Eq<Iterable<*>> {
1011

@@ -212,3 +213,4 @@ object IterableEq : Eq<Iterable<*>> {
212213

213214
private fun generateError(actual: Any, expected: Any) = failure(Expected(expected.print()), Actual(actual.print()))
214215
}
216+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package io.kotest.equals
2+
3+
import io.kotest.equals.types.byObjectEquality
4+
5+
interface Equality<T: Any?> {
6+
fun name(): String
7+
8+
fun verify(actual: T, expected: T) : EqualityResult
9+
10+
companion object {
11+
fun <T> default() = byObjectEquality<T>()
12+
}
13+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package io.kotest.equals
2+
3+
import io.kotest.assertions.print.print
4+
5+
interface EqualityResult {
6+
fun areEqual(): Boolean
7+
8+
fun details(): EqualityResultDetails
9+
10+
companion object {
11+
fun <T> equal(actual: T, expected: T, verifier: Equality<*>): SimpleEqualityResult {
12+
return create(equal = true, actual = actual, expected = expected, verifier = verifier)
13+
}
14+
15+
fun <T> notEqual(actual: T, expected: T, verifier: Equality<*>): SimpleEqualityResult {
16+
return create(equal = false, actual = actual, expected = expected, verifier = verifier)
17+
}
18+
19+
private fun <T> create(
20+
equal: Boolean,
21+
actual: T,
22+
expected: T,
23+
verifier: Equality<*>
24+
): SimpleEqualityResult {
25+
return SimpleEqualityResult(
26+
equal = equal,
27+
detailsValue = SimpleEqualityResultDetail(
28+
explainFn = {
29+
val expectedStr = expected.print().value
30+
val actualStr = actual.print().value
31+
return@SimpleEqualityResultDetail """
32+
| $expectedStr is ${if (equal) "" else "not "}equal to $actualStr by ${verifier.name()}
33+
| Expected: $expectedStr
34+
| Actual : $actualStr
35+
""".trimMargin()
36+
}
37+
)
38+
)
39+
}
40+
}
41+
}
42+
43+
fun EqualityResult.areNotEqual() = !areEqual()
44+
45+
interface EqualityResultDetails {
46+
fun explain(): String
47+
48+
companion object {
49+
fun create(reasonFn: () -> String): EqualityResultDetails {
50+
return object : EqualityResultDetails {
51+
override fun explain(): String = reasonFn()
52+
}
53+
}
54+
}
55+
}
56+
57+
data class SimpleEqualityResult(
58+
val equal: Boolean,
59+
val detailsValue: EqualityResultDetails,
60+
) : EqualityResult {
61+
fun withDetails(details: EqualityResultDetails): SimpleEqualityResult {
62+
return copy(detailsValue = details)
63+
}
64+
65+
fun withDetails(explainFn: () -> String): SimpleEqualityResult {
66+
return withDetails(SimpleEqualityResultDetail(explainFn))
67+
}
68+
69+
override fun areEqual(): Boolean = equal
70+
71+
override fun details(): EqualityResultDetails = detailsValue
72+
}
73+
74+
data class SimpleEqualityResultDetail(
75+
val explainFn: () -> String
76+
) : EqualityResultDetails {
77+
override fun explain(): String = explainFn()
78+
}

0 commit comments

Comments
 (0)