Our team is building a web service with Kotlin and Springboot that uses Google Cloud Spanner as a datastore.
Spanner has this cool feature called commit timestamps that records the time at which a row is committed to the database in a specified column.
The problem with this feature is that it makes it difficult to write unit tests. In particular, I'd like to write at test that looks like this:
@Test
fun saveAndLoadTest() {
val id = UUID.randomUuid().toString()
val expected = Foo(id)
dao.save(expected)
val actual = dao.load(id)
assertThat(actual).isEqualTo(expected)
}
but this doesn't work if Foo has a field that is populated by Spanner's commit timestamp, because that field will differ when actual is compared to expected.
@Table("Foo")
data class Foo (
@PrimaryKey
val id: String,
// the value of this field will differ before and after the object is written to the database
@Column(spannerCommitTimestamp = true)
val createdTimestamp: Timestamp = Timestamp.now()
)
To get around this, I wrote an extension function that configures the way that the two instances of Foo are compared. It looks something like this:
fun <T> ObjectAssert<T>.isEqualIgnoringTimestamps(other: T): ObjectAssert<T> {
this.usingRecursiveComparison()
.ignoringFieldsMatchingRegexes("createdTimestamp.*")
.isEqualTo(other)
return this
}
If I update the unit test to use my extension function, the assertion passes because any field whose name starts with createdTimestamp will be ignored:
@Test
fun saveAndLoadTest() {
val id = UUID.randomUuid().toString()
val expected = Foo(id)
dao.save(expected)
val actual = dao.load(id)
// at this point, we're basically only checking that the `id` fields are the same
assertThat(actual).isEqualIgnoringTimestamps(expected)
}
That's great, but it seems that the org.assertj.core.api.ObjectAssert.usingRecursiveComparison() configuration isn't used for child objects. This means that if I complicate Foo a little bit, the extension function stops working:
@Table("Foo")
data class Foo (
@PrimaryKey
val id: String,
// the "recursive" comparison rules won't be applied to this child object
val child: Foo,
@Column(spannerCommitTimestamp = true)
val createdTimestamp: Timestamp = Timestamp.now()
)
Now the unit test fails with an error message that looks like this:
Expecting:
<Foo(id=adfsikp87r6yu0hwy4pyg8oi9, child=Foo(id=cs6ne8m8tilcbhxq3s4ejxn5n, child=null, createdTimestamp=2021-06-18T20:08:37.142614000Z), createdTimestamp=2021-06-18T20:08:37.142614000Z)>
to be equal to:
<Foo(id=adfsikp87r6yu0hwy4pyg8oi9, child=Foo(id=cs6ne8m8tilcbhxq3s4ejxn5n, child=null, createdTimestamp=2021-06-18T20:08:37.352000000Z), createdTimestamp=2021-06-18T20:08:37.352000000Z)>
when recursively comparing field by field, but found the following difference:
field/property 'child' differ:
- actual value : Foo(id=cs6ne8m8tilcbhxq3s4ejxn5n, child=null, createdTimestamp=2021-06-18T20:08:37.352000000Z)
- expected value : Foo(id=cs6ne8m8tilcbhxq3s4ejxn5n, child=null, createdTimestamp=2021-06-18T20:08:37.142614000Z)
The recursive comparison was performed with this configuration:
- the fields matching the following regexes were ignored in the comparison: createdTimestamp.*
- these types were compared with the following comparators:
- java.lang.Double -> DoubleComparator[precision=1.0E-15]
- java.lang.Float -> FloatComparator[precision=1.0E-6]
- actual and expected objects and their fields were compared field by field recursively even if they were not of the same type, this allows for example to compare a Person to a PersonDto (call strictTypeChecking(true) to change that behavior).
The problem here appears to be that the child.createdTimestamp fields are not being ignored during the comparison, even though I've told the comparator to ignore fields called createdTimestamp. They are, of course, being ignored at the top level - just not in the child object.
Note that the same problem occurs if I tweak the recursive comparison configuration to ignore fields based on object type instead of name:
fun <T> ObjectAssert<T>.isEqualIgnoringTimestamps(other: T): ObjectAssert<T> {
this.usingRecursiveComparison()
.ignoringFieldsOfTypes(Timestamp::class.java)
.isEqualTo(other)
return this
}
Either way, the createdTimestamp field is ignored in the having object, but is not ignored in the has-a object.
So, with all of that explanation out of the way: What should I do about this problem?
.equals(...) and .hashCode() methods on Foo such that the createdTimestamp field is ignored, but then I'm leaking test code into production code and that makes me feel ickytl;dr: My database populates a created timestamp on my object at commit time. This makes it tricky to write unit tests because the object is changed when it is saved. How do I get around this problem?
I believe your issue comes from a combination of two things:
The error shows that the child fields were not equal, this indicates to me that your Foo class has an overridden equals method (likely from being a data class) and that you are using an AssertJ Core version < 3.17.0 as from 3.17.0 even if a class has overridden equals the recursive comparison would not use it and would compare its fields (note that by default root objects are always compared field by field).
If you are stuck with your old version simply use ignoringAllOverriddenEquals.
Then the second issue is your regex is not doing what you expect it should do, instead of createdTimestamp.*, try .*createdTimestamp.
Why is that? Fields are expressed by their location from the root object (i.e the object under test) so when you specify createdTimestamp.* it means any fields of the root object starting with createdTimestamp, it does not mean any createdTimestamp fields.
In your example it matches Foo.createdTimestamp as Foo is the root type being compared but it does not match Foo.child.createdTimestamp, to ignore the latter, you must specify child\.createdTimestamp (\. is for the regex not to interpret . as any character).
This does not really solve your issue because now child.child.createdTimestamp is not ignored but this is where regex are handy, .*createdTimestamp would do the job since it means: "any field location ending with createdTimestamp".
The related documentation is here:
I guess the documentation could show more examples of field regex usage and how they are interpreted.