Skip to content

Commit

Permalink
Support binding properties to specific Arbs when doing reflective bin…
Browse files Browse the repository at this point in the history
…ding (#3358)
  • Loading branch information
Kantis committed Nov 18, 2023
1 parent d7baec3 commit 23c09ac
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 25 deletions.
27 changes: 26 additions & 1 deletion documentation/docs/proptest/reflective_arbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,30 @@ Reflective binding is supported for:
* `LocalDate`, `LocalDateTime`, `LocalTime`, `Period`, `Instant` from `java.time`
* `BigDecimal`, `BigInteger`
* Collections (`Set`, `List`, `Map`)
* Classes for which an Arb has been provided through `providedArbs`
* Properties and types for which an Arb has been provided through `providedArbs`, see below

## Provided Arbs

When doing reflective binding, Kotest supports a builder API to provide `Arb`s for specific types (classes) and properties.
Binding specific properties allow greater control in cases where types might be more widely used, like primitives for instance.

Example:

```kotlin
data class User(
val name: String,
val password: String,
val age: Int,
)

// in some spec
context("Some tests with an arbitrary user") {
checkAll(Arb.bind<User> {
bind(User::name to Arb.string(1..10))
bind(User::password to Arb.string(24..80)) // binds a specific property to an arb
bind(Int::class to Arb.int(0..100)) // binds a type to an arb
}) { user ->
// ...
}
}
```
14 changes: 11 additions & 3 deletions kotest-property/api/kotest-property.api
Original file line number Diff line number Diff line change
Expand Up @@ -1066,8 +1066,8 @@ public final class io/kotest/property/arbitrary/IntsKt {
}

public final class io/kotest/property/arbitrary/JvmbindKt {
public static final fun bind (Lio/kotest/property/Arb$Companion;Ljava/util/Map;Ljava/util/Map;Lkotlin/reflect/KClass;Lkotlin/reflect/KType;)Lio/kotest/property/Arb;
public static final fun bind (Lio/kotest/property/Arb$Companion;Ljava/util/Map;Lkotlin/reflect/KClass;)Lio/kotest/property/Arb;
public static final fun bind (Lio/kotest/property/Arb$Companion;Ljava/util/Map;Lkotlin/reflect/KClass;Lkotlin/reflect/KType;)Lio/kotest/property/Arb;
}

public final class io/kotest/property/arbitrary/ListShrinker : io/kotest/property/Shrinker {
Expand Down Expand Up @@ -1159,6 +1159,14 @@ public final class io/kotest/property/arbitrary/NullKt {
public static synthetic fun orNull$default (Lio/kotest/property/Arb;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/kotest/property/Arb;
}

public final class io/kotest/property/arbitrary/ProvidedArbsBuilder {
public fun <init> ()V
public final fun bindClass (Lkotlin/Pair;)V
public final fun bindProperty (Lkotlin/Pair;)V
public final fun getClassArbs ()Ljava/util/Map;
public final fun getPropertyArbs ()Ljava/util/Map;
}

public final class io/kotest/property/arbitrary/RangeKt {
public static final fun intRange (Lio/kotest/property/Arb$Companion;Lkotlin/ranges/IntRange;)Lio/kotest/property/Arb;
}
Expand Down Expand Up @@ -1251,8 +1259,8 @@ public final class io/kotest/property/arbitrary/StringsjvmKt {
}

public final class io/kotest/property/arbitrary/TargetDefaultForClassNameKt {
public static final fun targetDefaultForType (Ljava/util/Map;Lkotlin/reflect/KType;)Lio/kotest/property/Arb;
public static synthetic fun targetDefaultForType$default (Ljava/util/Map;Lkotlin/reflect/KType;ILjava/lang/Object;)Lio/kotest/property/Arb;
public static final fun targetDefaultForType (Ljava/util/Map;Ljava/util/Map;Lkotlin/reflect/KType;)Lio/kotest/property/Arb;
public static synthetic fun targetDefaultForType$default (Ljava/util/Map;Ljava/util/Map;Lkotlin/reflect/KType;ILjava/lang/Object;)Lio/kotest/property/Arb;
}

public final class io/kotest/property/arbitrary/TimezoneKt {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.kotest.property.Arb
import io.kotest.property.resolution.GlobalArbResolver
import kotlin.reflect.KClass
import kotlin.reflect.KParameter
import kotlin.reflect.KProperty1
import kotlin.reflect.KType
import kotlin.reflect.KVisibility
import kotlin.reflect.full.primaryConstructor
Expand All @@ -29,7 +30,12 @@ import kotlin.reflect.typeOf
* - Classes for which an [Arb] has been provided through [providedArbs]
*/
inline fun <reified T : Any> Arb.Companion.bind(providedArbs: Map<KClass<*>, Arb<*>> = emptyMap()): Arb<T> =
bind(providedArbs, T::class, typeOf<T>())
bind(providedArbs, emptyMap(), T::class, typeOf<T>())

inline fun <reified T : Any> Arb.Companion.bind(builder: ProvidedArbsBuilder.() -> Unit): Arb<T> =
ProvidedArbsBuilder().apply(builder).run {
bind(classArbs, propertyArbs, T::class, typeOf<T>())
}

/**
* Alias for [Arb.Companion.bind]
Expand All @@ -54,13 +60,23 @@ inline fun <reified T : Any> Arb.Companion.bind(providedArbs: Map<KClass<*>, Arb
inline fun <reified T : Any> Arb.Companion.data(providedArbs: Map<KClass<*>, Arb<*>> = emptyMap()): Arb<T> =
Arb.bind(providedArbs)

inline fun <reified T : Any> Arb.Companion.data(builder: ProvidedArbsBuilder.() -> Unit): Arb<T> =
ProvidedArbsBuilder().apply(builder).run {
bind(classArbs, propertyArbs, T::class, typeOf<T>())
}

/**
* **Do not call directly**
*
* Callers should use [Arb.Companion.bind] without [KClass] and [KType] parameters instead.
*/
fun <T : Any> Arb.Companion.bind(providedArbs: Map<KClass<*>, Arb<*>>, kclass: KClass<T>, type: KType): Arb<T> {
val arb = Arb.forType(providedArbs, type)
fun <T : Any> Arb.Companion.bind(
providedArbs: Map<KClass<*>, Arb<*>>,
arbsForProps: Map<KProperty1<*, *>, Arb<*>>,
kclass: KClass<T>,
type: KType
): Arb<T> {
val arb = Arb.forType(providedArbs, arbsForProps, type)
?: error("Could not locate generator for ${kclass.simpleName}, consider making it a dataclass or provide an Arb for it.")
return arb as Arb<T>
}
Expand All @@ -72,12 +88,16 @@ fun <T : Any> Arb.Companion.bind(providedArbs: Map<KClass<*>, Arb<*>>, kclass: K
* See [#3362](https://github.com/kotest/kotest/issues/3362) for an example of such usage.
*/
@DelicateKotest
fun <T : Any> Arb.Companion.bind(providedArbs: Map<KClass<*>, Arb<*>>, kclass: KClass<T>): Arb<T> {
return forClassUsingConstructor(providedArbs, kclass)
fun <T : Any> Arb.Companion.bind(
providedArbs: Map<KClass<*>, Arb<*>>,
kclass: KClass<T>
): Arb<T> {
return forClassUsingConstructor(providedArbs, emptyMap(), kclass)
}

internal fun <T : Any> Arb.Companion.forClassUsingConstructor(
providedArbs: Map<KClass<*>, Arb<*>>,
arbsForProperties: Map<KProperty1<*, *>, Arb<*>>,
kclass: KClass<T>
): Arb<T> {
val className = kclass.qualifiedName ?: kclass.simpleName
Expand All @@ -89,26 +109,61 @@ internal fun <T : Any> Arb.Companion.forClassUsingConstructor(
return Arb.constant(constructor.call())
}

val relevantProps = arbsForProperties.filter { it.key.parameters[0].type == constructor.returnType }
.toList()

val arbs: List<Arb<*>> = constructor.parameters.map { param ->
val arb = arbForParameter(providedArbs, className, param)
val arbForProp = relevantProps.firstOrNull { (key, _) -> key.name == param.name }?.second
val arb = when {
arbForProp != null -> arbForProp
else -> arbForParameter(providedArbs, arbsForProperties, className, param)
}

if (param.type.isMarkedNullable) arb.orNull() else arb
}

return Arb.bind(arbs) { params -> constructor.call(*params.toTypedArray()) }
}

private fun arbForParameter(providedArbs: Map<KClass<*>, Arb<*>>, className: String?, param: KParameter): Arb<*> {
private fun arbForParameter(
providedArbs: Map<KClass<*>, Arb<*>>,
arbsForProps: Map<KProperty1<*, *>, Arb<*>>,
className: String?,
param: KParameter
): Arb<*> {
val arb =
try {
Arb.forType(providedArbs, param.type)
Arb.forType(providedArbs, arbsForProps, param.type)
} catch (e: IllegalStateException) {
throw IllegalStateException("Failed to create generator for parameter $className.${param.name}", e)
}
return arb ?: error("Could not locate generator for parameter $className.${param.name}")
}

internal fun Arb.Companion.forType(providedArbs: Map<KClass<*>, Arb<*>>, type: KType): Arb<*>? {
internal fun Arb.Companion.forType(
providedArbs: Map<KClass<*>, Arb<*>>,
arbsForProperties: Map<KProperty1<*, *>, Arb<*>>,
type: KType
): Arb<*>? {
return (type.classifier as? KClass<*>)?.let { providedArbs[it] ?: defaultForClass(it) }
?: GlobalArbResolver.resolve(type)
?: targetDefaultForType(providedArbs, type)
?: targetDefaultForType(providedArbs, arbsForProperties, type)
}

class ProvidedArbsBuilder {
private val _classArbs = mutableMapOf<KClass<*>, Arb<*>>()
private val _propertyArbs = mutableMapOf<KProperty1<*, *>, Arb<*>>()

val classArbs: Map<KClass<*>, Arb<*>> get() = _classArbs
val propertyArbs: Map<KProperty1<*, *>, Arb<*>> get() = _propertyArbs

@JvmName("bindClass")
fun bind(mapping: Pair<KClass<*>, Arb<*>>) {
_classArbs[mapping.first] = mapping.second
}

@JvmName("bindProperty")
fun bind(mapping: Pair<KProperty1<*, *>, Arb<*>>) {
_propertyArbs[mapping.first] = mapping.second
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import java.time.YearMonth
import java.time.ZonedDateTime
import java.util.Date
import kotlin.reflect.KClass
import kotlin.reflect.KParameter
import kotlin.reflect.KProperty1
import kotlin.reflect.KType
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.javaType
Expand All @@ -26,7 +28,11 @@ import kotlin.reflect.typeOf
@Deprecated("This logic has moved to ArbResolver and this function will be removed in 5.6. Since 5.5")
actual inline fun <reified A> targetDefaultForClass(): Arb<A>? = targetDefaultForType(type = typeOf<A>()) as Arb<A>?

fun targetDefaultForType(providedArbs: Map<KClass<*>, Arb<*>> = emptyMap(), type: KType): Arb<*>? {
fun targetDefaultForType(
providedArbs: Map<KClass<*>, Arb<*>> = emptyMap(),
arbsForProps: Map<KProperty1<*, *>, Arb<*>> = emptyMap(),
type: KType
): Arb<*>? {
when (type) {
typeOf<Instant>(), typeOf<Instant?>() -> Arb.instant()
typeOf<Date>(), typeOf<Date?>() -> Arb.javaDate()
Expand All @@ -46,11 +52,11 @@ fun targetDefaultForType(providedArbs: Map<KClass<*>, Arb<*>> = emptyMap(), type
return when {
clazz.isSubclassOf(List::class) -> {
val upperBound = type.arguments.first().type ?: error("No bound for List")
Arb.list(Arb.forType(providedArbs, upperBound) as Arb<*>)
Arb.list(Arb.forType(providedArbs, arbsForProps, upperBound) as Arb<*>)
}
clazz.java.isArray -> {
val upperBound = type.arguments.first().type ?: error("No bound for Array")
Arb.array(Arb.forType(providedArbs, upperBound) as Arb<*>) {
Arb.array(Arb.forType(providedArbs, arbsForProps, upperBound) as Arb<*>) {
val upperBoundKClass = (upperBound.classifier as? KClass<*>) ?: error("No classifier for $upperBound")
val array = java.lang.reflect.Array.newInstance(upperBoundKClass.javaObjectType, this.size) as Array<Any?>
for ((i, item) in this.withIndex()) {
Expand All @@ -64,36 +70,36 @@ fun targetDefaultForType(providedArbs: Map<KClass<*>, Arb<*>> = emptyMap(), type
val upperBoundKClass = (upperBound.classifier as? KClass<*>)
if (upperBoundKClass != null && upperBoundKClass.isSubclassOf(Enum::class)) {
val maxElements = Class.forName(upperBoundKClass.java.name).enumConstants.size
Arb.set(Arb.forType(providedArbs, upperBound) as Arb<*>, 0..maxElements)
Arb.set(Arb.forType(providedArbs, arbsForProps, upperBound) as Arb<*>, 0..maxElements)
} else if(upperBoundKClass != null && upperBoundKClass.isSealed) {
val maxElements = upperBoundKClass.sealedSubclasses.size
Arb.set(Arb.forType(providedArbs, upperBound) as Arb<*>, 0..maxElements)
Arb.set(Arb.forType(providedArbs, arbsForProps, upperBound) as Arb<*>, 0..maxElements)
} else {
Arb.set(Arb.forType(providedArbs, upperBound) as Arb<*>)
Arb.set(Arb.forType(providedArbs, arbsForProps, upperBound) as Arb<*>)
}
}
clazz.isSubclassOf(Pair::class) -> {
val first = type.arguments[0].type ?: error("No bound for first type parameter of Pair")
val second = type.arguments[1].type ?: error("No bound for second type parameter of Pair")
Arb.pair(Arb.forType(providedArbs, first)!!, Arb.forType(providedArbs, second)!!)
Arb.pair(Arb.forType(providedArbs, arbsForProps, first)!!, Arb.forType(providedArbs, arbsForProps, second)!!)
}
clazz.isSubclassOf(Map::class) -> {
// map key type can have or have not variance
val first = type.arguments[0].type ?: error("No bound for first type parameter of Map<K, V>")
val second = type.arguments[1].type ?: error("No bound for second type parameter of Map<K, V>")
Arb.map(Arb.forType(providedArbs, first)!!, Arb.forType(providedArbs, second)!!)
Arb.map(Arb.forType(providedArbs, arbsForProps, first)!!, Arb.forType(providedArbs, arbsForProps, second)!!)
}
clazz.isSubclassOf(Enum::class) -> {
Arb.of(Class.forName(clazz.java.name).enumConstants.map { it as Enum<*> })
}
clazz.objectInstance != null -> Arb.constant(clazz.objectInstance!!)
clazz.isSealed -> {
Arb.choice(clazz.sealedSubclasses.map { subclass ->
subclass.objectInstance?.let { Arb.constant(it) } ?: Arb.forClassUsingConstructor(providedArbs, subclass)
subclass.objectInstance?.let { Arb.constant(it) } ?: Arb.forClassUsingConstructor(providedArbs, arbsForProps, subclass)
})
}
else -> {
Arb.forClassUsingConstructor(providedArbs, clazz)
Arb.forClassUsingConstructor(providedArbs, arbsForProps, clazz)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ internal actual fun platformArbResolver(): ArbResolver = JvmArbResolver

object JvmArbResolver : ArbResolver {
override fun resolve(type: KType): Arb<*>? {
return targetDefaultForType(emptyMap(), type)
return targetDefaultForType(emptyMap(), emptyMap(), type)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,29 @@ package com.sksamuel.kotest.property.arbitrary
import io.kotest.assertions.throwables.shouldThrowAny
import io.kotest.core.spec.style.StringSpec
import io.kotest.extensions.system.captureStandardOut
import io.kotest.inspectors.forAll
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveAtLeastSize
import io.kotest.matchers.comparables.beGreaterThan
import io.kotest.matchers.comparables.beLessThan
import io.kotest.matchers.comparables.shouldBeGreaterThan
import io.kotest.matchers.ints.shouldBeLessThan
import io.kotest.matchers.should
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldEndWith
import io.kotest.matchers.string.shouldNotStartWith
import io.kotest.matchers.string.shouldStartWith
import io.kotest.property.Arb
import io.kotest.property.EdgeConfig
import io.kotest.property.RandomSource
import io.kotest.property.arbitrary.bind
import io.kotest.property.arbitrary.boolean
import io.kotest.property.arbitrary.constant
import io.kotest.property.arbitrary.double
import io.kotest.property.arbitrary.filter
import io.kotest.property.arbitrary.map
import io.kotest.property.arbitrary.negativeInt
import io.kotest.property.arbitrary.positiveInt
import io.kotest.property.arbitrary.string
Expand Down Expand Up @@ -624,4 +631,45 @@ class BindTest : StringSpec({
stdout shouldContain "Shrink result"
stdout shouldContain "Person(name=, age=-1)"
}

"Bind using properties" {
checkAll(Arb.bind<User> {
bind(User::email to Arb.string().map { s -> "$s@yahoo.com" })
}) { user ->
user.email shouldEndWith "@yahoo.com"
}
}

"Binding properties in a nested structure should work" {
data class Person(val id: Int, val name: String)
data class Family(val name: String, val persons: List<Person>)

checkAll(Arb.bind<Family> {
bind(Family::name to Arb.string().map { s -> "Flanders-$s" })
bind(Person::id to Arb.positiveInt())
}) { family ->
family.name shouldStartWith "Flanders-"
family.persons.forAll {
it.name shouldNotStartWith "Flanders-"
it.id shouldBeGreaterThan 0
}
}
}

"When binding using properties and classes, properties should take precedence no matter the order of binding" {
checkAll(Arb.bind<User> {
bind(Int::class to Arb.constant(3))
bind(User::id to Arb.constant(7))
}) {
it.id shouldBe 7
}

checkAll(Arb.bind<User> {
bind(User::id to Arb.constant(7))
bind(Int::class to Arb.constant(3))
}) {
it.id shouldBe 7
}
}

})

0 comments on commit 23c09ac

Please sign in to comment.