如何以声明方式Assert/验证/匹配Kotlin数据类结构

epggiuax  于 12个月前  发布在  Kotlin
关注(0)|答案(2)|浏览(143)

假设我在Kotlin中有以下数据结构(从实际需要简化):

data class RootClass(
    val a: String,
    val nestedContent: MiddleClass,
) : Result

data class MiddleClass(
    val foo: String,
    val leaf: Leaf,
)

data class Leaf(
    val value: String,
    val leaf: Leaf?,
)

字符串
为了在测试中进行Assert/验证,是否有某种方法(例如使用某个库)以声明的方式定义预期结果,例如允许忽略任意字段。

RootClass(
    a = "Foo",
    nestedContent = MiddleClass(
        foo = anyString(),
        leaf = Leaf(
            value = "leaf-value",
            leaf = any(Leaf)
        )
    )
)


我研究了一下assertK、Hamcrest和mockK匹配器等,还不是很深入,但还没有找到“最佳方法”。对于典型的用例,通常可以创建自定义帮助程序来检查想要的字段,但对上面的东西进行测试偶尔会很有用,而且容易阅读。
更新:对于“通过示例说明”的用法,“照原样”获得预期结果是有用的。

ua4mk5z4

ua4mk5z41#

如果你想声明,type safe builders模式非常有用。
首先,声明一个表示匹配器的接口。

interface Matcher<in T> {
    fun match(value: T): Boolean
}

字符串
然后你就可以创建各种各样的实现了(例如Hamcrest中提供的各种匹配器)。现在,我只需要这些简单的:

data class EqualityMatcher<T>(val value: T): Matcher<T> {
    override fun match(value: T) = value == this.value
}

object AnyNonNull: Matcher<Any?> {
    override fun match(value: Any?) = value != null;
}

data class PropertyMatcher<T, V>(
    val property: KProperty1<T, V>,
    val downstream: Matcher<V>
): Matcher<T> {
    override fun match(value: T) = downstream.match(property.get(value))
}


然后我们可以编写一个构建器,它构建了一种新的匹配器,只有当它包含的Matcher<T>列表都匹配时才匹配。

data class AllMatcher<T>(val matchers: MutableList<Matcher<T>> = mutableListOf()): Matcher<T> {
    override fun match(value: T) = matchers.all { it.match(value) }

    infix fun <V> KProperty1<T, V>.shouldBe(value: V) {
        matchers.add(PropertyMatcher(this, EqualityMatcher(value)))
    }

    infix fun <V> KProperty1<T, V>.shouldMatch(matcher: Matcher<V>) {
        matchers.add(PropertyMatcher(this, matcher))
    }

    inline infix fun <V> KProperty1<T, V>.shouldMatch(block: AllMatcher<V>.() -> Unit) {
        matchers.add(PropertyMatcher(this, match(block)))
    }
}

inline fun <T> match(block: AllMatcher<T>.() -> Unit): Matcher<T> =
    AllMatcher<T>().apply(block)


这样,我们就可以创建一个Matcher<RootClass>,就像你的问题中的那样:

val matcher = match {
    RootClass::a shouldBe "Foo"
    RootClass::nestedContent shouldMatch {
        MiddleClass::foo shouldMatch AnyNonNull
        MiddleClass::leaf shouldMatch {
            Leaf::value shouldBe "leaf-value"
            Leaf::leaf shouldMatch AnyNonNull
        }
    }
}


通过对名称进行一些调整,您可以使其更具可读性。
作为扩展,我在这里添加了一个AnyMatcher

class AllMatcher<T>(matchers: MutableList<Matcher<T>> = mutableListOf()): ComposableMatcher<T>(matchers) {
    override fun match(value: T) = matchers.all { it.match(value) }
}

class AnyMatcher<T>(matchers: MutableList<Matcher<T>> = mutableListOf()): ComposableMatcher<T>(matchers) {
    override fun match(value: T) = matchers.any { it.match(value) }
}

inline fun <T> match(block: AllMatcher<T>.() -> Unit): Matcher<T> =
    AllMatcher<T>().apply(block)

inline fun <T> matchAnyOf(block: AnyMatcher<T>.() -> Unit): Matcher<T> =
    AnyMatcher<T>().apply(block)

// I extracted the shouldBe and shouldMatch functions to this base class
abstract class ComposableMatcher<T>(val matchers: MutableList<Matcher<T>>): Matcher<T> {
    infix fun <V> KProperty1<T, V>.shouldBe(value: V) {
        matchers.add(PropertyMatcher(this, EqualityMatcher(value)))
    }

    infix fun <V> KProperty1<T, V>.shouldMatch(matcher: Matcher<V>) {
        matchers.add(PropertyMatcher(this, matcher))
    }

    inline infix fun <V> KProperty1<T, V>.shouldMatch(block: AllMatcher<V>.() -> Unit) {
        matchers.add(PropertyMatcher(this, match(block)))
    }

    inline infix fun<V> KProperty1<T, V>.shouldMatchAnyOf(block: AnyMatcher<V>.() -> Unit) {
        matchers.add(PropertyMatcher(this, matchAnyOf(block)))
    }
}

uklbhaso

uklbhaso2#

另一种表示Matcher<T>的方法是一个函数,它接受T,并以某种方式更新MatcherContext

typealias Matcher<T> = context(MatcherContext) T.() -> Unit

data class MatcherContext(
    val mode: Mode, 
    var matched: Boolean = mode == Mode.AND
) {
    // AND is for something like "allOf { ... }"
    // OR is for something like "anyOf { ... }"
    enum class Mode {
        AND, OR
    }

    fun update(value: Boolean) {
        matched = when (mode) {
            Mode.AND -> matched && value
            Mode.OR -> matched || value
        }
    }
}

字符串
请注意,我使用了Match<T>类型的上下文接收器和常规接收器,以使use-site尽可能具有声明性。use site不需要一直传递MatcherContext
现在anyNonNull匹配器可以写成:

val anyNonNull: Matcher<Any?> = { update(this != null) }


为了在结构上匹配像RootClass这样的类型,我们的想法是将Matcher<T>写为lambda。在lambda内部,我们将编写最终调用MatcherContext.update的语句。这些语句将调用像这样的方法:

context(MatcherContext)
infix fun <T> T.`=`(value: T) {
    update(this == value)
}

context(MatcherContext)
infix fun <T> T.`=`(matcher: Matcher<T>) {
    // we need to create a new AND context for matching the given matcher, 
    // because we might be in an OR context right now
    update(MatcherContext(MatcherContext.Mode.AND).apply { matcher(this@apply, this@`=`) }.matched)
}


我特意将其命名为=,以使使用站点看起来更好。=函数(以及其他接受上下文接收器的函数)不幸的是现在不能是inline,因为this compiler bug
现在我们可以在问题中编写匹配器:

val matcher: Matcher<RootClass> = {
    a `=` "Foo"
    nestedContent `=` {
        foo `=` anyNonNull
        leaf `=` {
            value `=` "leaf-value"
            leaf `=` anyNonNull
        }
    }
}


请注意,每个=调用都会更新当前上下文。=之后的每个{ ... }都会打开一个新的AND上下文。
返回Boolean的实际match函数也很简单-打开一个上下文,将匹配器应用于上下文,然后返回其matched属性。

inline fun <T> match(value: T, matcher: Matcher<T>) =
    MatcherContext(MatcherContext.Mode.AND).apply { matcher(this, value) }.matched


my other answer相比,它不需要到处引用属性,但它并不懒惰。例如,在AND上下文中,即使它找到不匹配的东西,它仍然会尝试评估其余的匹配器。
现在是时候做一些扩展了:

// this is just an alias of "=". A name like this makes it more readable to use standalone (i.e. without a left-hand-side)
context(MatcherContext)
fun <T> T.matchAll(matcher: Matcher<T>) = this `=` matcher

context(MatcherContext)
fun <T> T.matchAny(matcher: Matcher<T>) {
    update(MatcherContext(MatcherContext.Mode.OR).apply { matcher(this@apply, this@matchAny) }.matched)
}

// Matcher<T> versions for matchAll and matchAny
fun <T> allOf(matcher: Matcher<T>): Matcher<T> = { matchAll(matcher) }
fun <T> anyOf(matcher: Matcher<T>): Matcher<T> = { matchAny(matcher) }

相关问题