Hello, I'm looking for an assertion library or any...
# test
n
Hello, I'm looking for an assertion library or any tips to allow me to assert that a data class has all its fields equals to another data class, except for the possible lambdas. I don't care about the lambdas since they can't be tested anyway, a UI test will actually take care of that. But the fields like
label
and other stuff has great value to test. I would like to avoid overriding the equal function of the data class for obvious reasons. Any ideas ?
Copy code
// Source code
data class Foo(
    val label: String,
    val onClicked: () -> Unit,
    val onValueChanged: (Int) -> Unit,
)

object FooMapper {
    fun map(
        label: String,
        viewModel: FooViewModel,
    ) = Foo(
        label = label,
        onClicked = { viewModel.onFooClicked() },
        onValueChanged = { viewModel.onValueChanged(it.toLong()) },
    )
}

class FooViewModel {
    fun onFooClicked() { /* whatever */ }
    fun onValueChanged(value: Long) { /* whatever */ }
}

// Unit tests
class FooMapperTest {
    @Test
    fun `foo map`() {
        // Given
        val label = "label"
        val fooViewModel = mockk<FooViewModel>()

        // When
        val result = FooMapper.map(label = label, viewModel = fooViewModel)

        // Then
        // !! What should I use there?
        assertEquals(
            Foo(label = label, onClicked = {}, onValueChanged = {}),
            result
        )
    }
}
One trick that kinda works is function reference, for example for the onClicked it works that way :
Copy code
object FooMapper {
    fun map(label: String, viewModel: FooViewModel) = Foo(
        label = label,
        onClicked = viewModel::onFooClicked,
        onValueChanged = { viewModel.onValueChanged(it.toLong()) },
    )
}

assertEquals(
    Foo(label = label, onClicked = fooViewModel::onFooClicked, onValueChanged = {}),
    result
)
But sometime some kind of mapping will happen in the UI and it should stay there, so the signature between the UiState and the Viewmodel are not the same (like in
onValueChanged
, which is an Int for example in the ui), and it doesn't work anymore since I need an "intermediate" lambda to do the mapping
e
you could try reflection
Copy code
infix fun Any?.reflectiveEquals(other: Any?): Boolean = when {
    this == null -> other == null
    other == null -> false
    else -> this::class == other::class && this::class.memberProperties.all { kproperty ->
        val a = (kproperty as KProperty1<Any, Any?>)(this)
        val b = kproperty(other)
        val classifier = kproperty.returnType.classifier
        when {
            classifier !is KClass<*> -> a == b
            classifier.isFun || classifier.isSubclassOf(Function::class) -> (a != null) == (b != null)
            classifier.isData -> a reflectiveEquals b
            else -> a == b
        }
    }
}
or write a KSP processor to generate a
Copy code
fun Foo.testEquals(other: Foo): Boolean
extension for every
Copy code
@GenerateTestEquals
data class Foo(...)
n
Thanks for those leads, I'll look into it! I've already heard "reflection is terrible for performances". May a thousand unit tests using reflection be noticeably longer than "manual" comparision (with a custom matcher for every data class)?
e
reflection on its own is not that bad, within an order of magnitude as a direct function call
and if you really need, you can use java's methodhandles to get even closer https://kotlinlang.slack.com/archives/C0BJ0GTE2/p1734361218482709?thread_ts=1734351792.722459&amp;cid=C0BJ0GTE2
of course KSP will let you generate regular Kotlin code which will run just like any other Kotlin code
but overall I've found that reflection is fast enough for tests
e.g. our codebase has a test which scans the classpath for every
@Serializable
class, tries to reflectively instantiate it (including instantiating parameters), and polymorphically serializes every one with every superclass to check that it works
there is some noticeable startup cost as kotlin.reflect loads metadata for all classes, which is ~200ms for us. but that only happens once, and then every test case runs in ~1ms
YMMV