How to assert/verify/match a Kotlin data class structure in a declarative way

142 Views Asked by At

Let's say I have a following data structure in Kotlin (simplified from the actual need):

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?,
)

For purpose of assertion/verification in tests, is there some way (with e.g. some library) to define the expected result in a declarative way, allowing e.g. ignoring arbitrary fields. So that the expected result could be presented somewhat in the following way

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

I've looked a bit into assertK, Hamcrest and mockK matchers, etc. not very deep yet but didn't find "an optimal way" yet. For typical use cases it's usually possible to e.g. create custom helpers that check the wanted fields but testing against something like above would be occasionally useful + easy to read.

UPDATE: Having the expected result "as such" would be useful for "specification by example" kind of usage.

2

There are 2 best solutions below

3
Sweeper On

The type safe builders pattern is very useful if you want to be declarative.

First, declare an interface that represents a matcher.

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

Then you can create all kinds of implementations for this (see the various matchers available in Hamcrest for example). For now, I'll just have these simple ones:

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))
}

Then we can write a builder that builds a new kind of matcher, that matches only if the list of Matcher<T>s it contains all match.

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)

With just that, we can already create a Matcher<RootClass> like the one in your question:

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
        }
    }
}

With some tweaking with the names, you can make this more readable.

As an extension, here I added an 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)))
    }
}
0
Sweeper On

Another way of representing a Matcher<T> would be a function that takes in a T, and updates a MatcherContext in some way.

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
        }
    }
}

Note that I am using context receivers and regular receivers in the type of Match<T> to make the use-site as declarative as possible. The use site wouldn't need to pass a MatcherContext around at all times.

Now an anyNonNull matcher can be written as:

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

For structurally matching a type like RootClass, the idea is that you would write a Matcher<T> as a lambda. Inside the lambda, you would write statements that eventually calls MatcherContext.update. The statements would be calls to methods like these:

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)
}

I deliberately named it = to make the use site look a lot nicer. The = function (and others that take a context receiver) unfortunately cannot be inline right now, because of this compiler bug.

Now we can write the matcher in the question:

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

Note that each of those = calls updates the current context. Every { ... } after = opens up a new AND context.

The actual match function that returns a Boolean is also simple - open up a context, apply the matcher to the context, then return its matched property.

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

Compared to my other answer, this doesn't need property references everywhere, but it is not lazy. e.g. In an AND context, even when it finds something that doesn't match, it will still try to evaluate the rest of the matchers.


Now it's time for some extensions:

// 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) }