-
Notifications
You must be signed in to change notification settings - Fork 628
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'master' into refactor-concurrency
- Loading branch information
Showing
9 changed files
with
938 additions
and
22 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
175 changes: 175 additions & 0 deletions
175
...sertions/kotest-assertions-core/src/jvmMain/kotlin/io/kotest/matchers/equality/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,175 @@ | ||
package io.kotest.matchers.equality | ||
|
||
import io.kotest.assertions.eq.eq | ||
import io.kotest.assertions.failure | ||
import io.kotest.assertions.print.print | ||
import io.kotest.mpp.bestName | ||
import kotlin.reflect.KClass | ||
import kotlin.reflect.full.isSubclassOf | ||
import kotlin.reflect.jvm.jvmName | ||
|
||
fun <T> compareUsingFields( | ||
actual: T, | ||
expected: T, | ||
config: FieldEqualityConfig, | ||
): CompareResult { | ||
return when { | ||
actual == null -> throw failure("Expected ${expected.print().value} but actual was null") | ||
expected == null -> throw failure("Expected null but actual was ${actual.print().value}") | ||
else -> compareFields(actual, expected, null, config) | ||
} | ||
} | ||
|
||
private fun compareFields(actual: Any?, expected: Any?, field: String?, config: FieldEqualityConfig): CompareResult { | ||
|
||
val props1 = actual.fields(config.predicates()) | ||
val props2 = expected.fields(config.predicates()) | ||
|
||
// types don't have to match but they should at least have the same fields | ||
if (props1 != props2) | ||
throw failure("Comparing type ${actual!!::class.jvmName} to ${expected!!::class.jvmName} with mismatched properties") | ||
|
||
return props1.fold(CompareResult(emptyList(), emptyMap())) { acc, prop -> | ||
println("Prop: " + prop.returnType.toString().replace("?", "")) | ||
val actualValue = prop.getter.call(actual) | ||
val expectedValue = prop.getter.call(expected) | ||
val name = if (field == null) prop.name else field + "." + prop.name | ||
val returnType = prop.returnType.classifier as KClass<*> | ||
acc.reduce(compareValue(actualValue, expectedValue, returnType, name, config)) | ||
} | ||
} | ||
|
||
private fun compareValue( | ||
actual: Any?, | ||
expected: Any?, | ||
type: KClass<*>, | ||
field: String, | ||
config: FieldEqualityConfig | ||
): CompareResult { | ||
println("Compare value $type from $actual $expected") | ||
|
||
return when { | ||
type.isSubclassOf(Collection::class) -> { | ||
val actualCollection = actual as Collection<*> | ||
val expectedCollection = expected as Collection<*> | ||
compareCollections(actualCollection, expectedCollection, field, config) | ||
} | ||
|
||
type.isSubclassOf(Map::class) -> { | ||
val actualMap = actual as Map<*, *> | ||
val expectedMap = expected as Map<*, *> | ||
compareMaps(actualMap, expectedMap, field, config) | ||
} | ||
|
||
useEq( | ||
actual, | ||
expected, | ||
type, | ||
config.useDefaultShouldBeForFields | ||
) -> { | ||
val throwable = eq(actual, expected) | ||
if (throwable == null) CompareResult.match(field) else CompareResult.single(field, throwable) | ||
} | ||
|
||
else -> compareFields(actual, expected, field, config) | ||
} | ||
} | ||
|
||
private fun compareCollections( | ||
actual: Collection<*>, | ||
expected: Collection<*>, | ||
field: String, | ||
config: FieldEqualityConfig | ||
): CompareResult { | ||
|
||
return if (actual.size != expected.size) | ||
CompareResult.single(field, failure("Collections differ in size: ${actual.size} != ${expected.size}")) | ||
else if (actual.isEmpty()) | ||
CompareResult.empty | ||
else { | ||
actual.zip(expected).withIndex().map { (index, value) -> | ||
// other element should be an instance of this element or vice version | ||
val elementName = "$field[$index]" | ||
when { | ||
value.first == null && value.second == null -> CompareResult.empty | ||
value.first == null -> CompareResult.single( | ||
elementName, | ||
failure("Expected ${value.second.print().value} but actual was null") | ||
) | ||
|
||
value.second == null -> CompareResult.single( | ||
elementName, | ||
failure("Expected null but actual was ${value.first.print().value}") | ||
) | ||
|
||
else -> compareValue(value.first, value.second, value.first!!::class, elementName, config) | ||
} | ||
}.reduce { a, op -> a.reduce(op) } | ||
} | ||
} | ||
|
||
private fun compareMaps( | ||
actual: Map<*, *>, | ||
expected: Map<*, *>, | ||
field: String, | ||
config: FieldEqualityConfig | ||
): CompareResult { | ||
|
||
return if (actual.size != expected.size) | ||
CompareResult.single(field, failure("Maps differ in size: ${actual.size} != ${expected.size}")) | ||
else if (actual.isEmpty()) | ||
CompareResult.empty | ||
else { | ||
actual.keys.map { key -> | ||
val a = actual[key] | ||
val b = expected[key] | ||
compareValue(a, b, a!!::class, "$field[$key]", config) | ||
}.reduce { a, op -> a.reduce(op) } | ||
} | ||
} | ||
|
||
|
||
data class CompareResult( | ||
val fields: List<String>, | ||
val errors: Map<String, Throwable>, | ||
) { | ||
|
||
companion object { | ||
val empty: CompareResult = CompareResult(emptyList(), emptyMap()) | ||
fun single(field: String, error: Throwable): CompareResult = CompareResult(listOf(field), mapOf(field to error)) | ||
fun match(field: String): CompareResult = CompareResult(listOf(field), emptyMap()) | ||
} | ||
|
||
fun withMatch(field: String) = CompareResult(fields + field, errors) | ||
fun withError(field: String, error: Throwable) = CompareResult(fields + field, errors + Pair(field, error)) | ||
fun reduce(other: CompareResult) = | ||
CompareResult(this.fields + other.fields, this.errors + other.errors) | ||
} | ||
|
||
private val builtins = setOf("boolean", "byte", "double", "float", "int", "long", "short") | ||
|
||
/** | ||
* Returns true if we should use an instance of [Eq] for comparison, rather than field by field recursion. | ||
*/ | ||
internal fun useEq( | ||
actual: Any?, | ||
expected: Any?, | ||
typeName: KClass<*>, | ||
useEqs: Collection<KClass<*>>, | ||
): Boolean { | ||
val expectedOrActualIsNull = actual == null || expected == null | ||
val typeIsJavaOrKotlinBuiltIn by lazy { | ||
val bestName = typeName.bestName() | ||
bestName.startsWith("kotlin") || | ||
bestName.startsWith("java") || | ||
builtins.contains(bestName) | ||
} | ||
val expectedOrActualIsEnum = actual is Enum<*> | ||
|| expected is Enum<*> | ||
|| (actual != null && actual::class.java.isEnum) | ||
|| (expected != null && expected::class.java.isEnum) | ||
return expectedOrActualIsNull | ||
|| typeIsJavaOrKotlinBuiltIn | ||
|| useEqs.contains(typeName) | ||
|| expectedOrActualIsEnum | ||
} |
147 changes: 147 additions & 0 deletions
147
...-assertions-core/src/jvmMain/kotlin/io/kotest/matchers/equality/equalToComparingFields.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,147 @@ | ||
package io.kotest.matchers.equality | ||
|
||
import io.kotest.assertions.print.print | ||
import io.kotest.matchers.Matcher | ||
import io.kotest.matchers.MatcherResult | ||
import io.kotest.matchers.should | ||
import io.kotest.matchers.shouldNot | ||
import kotlin.reflect.KClass | ||
import kotlin.reflect.KProperty | ||
|
||
infix fun <T : Any> T.shouldBeEqualUsingFields(other: T): T { | ||
val config = FieldEqualityConfig() | ||
this should beEqualUsingFields(other, config) | ||
return this | ||
} | ||
|
||
infix fun <T : Any> T.shouldNotBeEqualUsingFields(other: T): T { | ||
val config = FieldEqualityConfig() | ||
this shouldNot beEqualUsingFields(other, config) | ||
return this | ||
} | ||
|
||
/** | ||
* Matcher that compares values using field by field comparison. | ||
* | ||
* This matcher should be used to check equality of two class for which you want to consider their fields for equality | ||
* instead of its `equals` method. | ||
* | ||
* This matcher recursively check equality of given values till we get a java class, kotlin class or fields for which we have | ||
* specified to use default shouldBe. Once we get a java class, kotlin class or specified field the equality of that fields | ||
* will be same as that we get with shouldBe matcher. | ||
* | ||
* @param block a configure block that can be use to configure the match, this must return the value to compare | ||
* | ||
* @see FieldsEqualityCheckConfig | ||
* | ||
* Example: | ||
* ``` | ||
* package org.foo.bar.domain | ||
* | ||
* class ANestedClass(val name: String, val nestedField: AnotherNestedClass) { | ||
* private val id = UUID.randomUUID() | ||
* } | ||
* | ||
* class AnotherNestedClass(val buffer: Buffer) { | ||
* val aComputedField: Int | ||
* get() = Random.nextInt() | ||
* } | ||
* | ||
* class SomeClass(val name: String, val randomId: UUID ,val nestedField: ANestedClass) | ||
* | ||
* someClass shouldBeEqualUsingFields { | ||
* | ||
* ignorePrivateFields = true | ||
* ignoreComputedFields = true | ||
* propertiesToExclude = listOf(SomeClass::randomId) | ||
* useDefaultShouldBeForFields = listOf("org.foo.bar.domain.AnotherNestedClass") | ||
* | ||
* anotherInstanceOfSomeClass | ||
* } | ||
* ``` | ||
* */ | ||
infix fun <T : Any> T.shouldBeEqualUsingFields(block: FieldEqualityConfig.() -> T): T { | ||
val config = FieldEqualityConfig() | ||
val other = block.invoke(config) | ||
this should beEqualUsingFields(other, config) | ||
return this | ||
} | ||
|
||
infix fun <T : Any> T.shouldNotBeEqualUsingFields(block: FieldEqualityConfig.() -> T): T { | ||
val config = FieldEqualityConfig() | ||
val other = block.invoke(config) | ||
this shouldNot beEqualUsingFields(other, config) | ||
return this | ||
} | ||
|
||
/** | ||
* Config for controlling the way shouldBeEqualUsingFields compares fields. | ||
* | ||
* Note: If both [includedProperties] and [excludedProperties] are not empty, an error will be thrown. | ||
* | ||
* | ||
* @property ignorePrivateFields specify whether to exclude private fields in comparison. Default true. | ||
* @property ignoreComputedFields specify whether to exclude computed fields in comparison. Default true. | ||
* @property includedProperties specify which fields to include. Default empty list. | ||
* @property excludedProperties specify which fields to exclude in comparison. Default emptyList. | ||
* @property useDefaultShouldBeForFields data types for which to use the standard shouldBe comparision | ||
* instead of recursive field by field comparison. | ||
* */ | ||
class FieldEqualityConfig { | ||
var ignorePrivateFields: Boolean = true | ||
var ignoreComputedFields: Boolean = true | ||
var includedProperties: Collection<KProperty<*>> = emptySet() | ||
var excludedProperties: Collection<KProperty<*>> = emptySet() | ||
var useDefaultShouldBeForFields: Collection<KClass<*>> = emptySet() | ||
} | ||
|
||
fun <T : Any> beEqualUsingFields(expected: T, config: FieldEqualityConfig): Matcher<T> { | ||
|
||
if (config.includedProperties.isNotEmpty() && config.excludedProperties.isNotEmpty()) | ||
error("Cannot set both includedProperties and excludedProperties") | ||
|
||
return object : Matcher<T> { | ||
override fun test(value: T): MatcherResult { | ||
return runCatching { compareUsingFields(value, expected, config) }.fold( | ||
{ result -> | ||
MatcherResult( | ||
result.errors.isEmpty(), | ||
{ | ||
"""Expected ${value.print().value} to equal ${expected.print().value} | ||
| | ||
|Using fields: | ||
|${result.fields.joinToString("\n") { " - $it" }} | ||
| | ||
|Fields that differ: | ||
|${result.errors.entries.joinToString("\n") { " - ${it.key} => ${it.value.message}" }} | ||
| | ||
""".trimMargin() | ||
}, | ||
{ | ||
"""Expected ${value.print().value} to not equal ${expected.print().value} | ||
| | ||
|Using fields: | ||
|${result.fields.joinToString("\n") { " - $it" }} | ||
| | ||
""".trimMargin() | ||
} | ||
) | ||
}, | ||
{ | ||
MatcherResult( | ||
false, | ||
{ | ||
"""Error using shouldBeEqualUsingFields matcher | ||
|${it.message} | ||
""".trimMargin() | ||
}, | ||
{ | ||
"""Error using shouldBeEqualUsingFields matcher | ||
|${it.message} | ||
""".trimMargin() | ||
}) | ||
} | ||
) | ||
} | ||
} | ||
} |
Oops, something went wrong.