Kotlin Assertion Libraries - Kotest Assertions

— Introduction — AssertJ — Strikt — AssertK — KoTest Assertions — Atrium — Kluent — Conclusions —
Kotest
Intro
In this blog post we will be looking at version 4.6.0 of Kotest assertions. It is a part of the larger Kotest Framework that has been around since 2015. Its main draws are a large wealth of supported matchers and a novel syntax on account of it fully embracing Kotlin’s infix
and extension function feature.
Installation
Kotest is its own testing framework, and also supports platforms outside the JVM like KotlinJS and Android. As such there is a downright overwhelming list of modules to pick from if you were to just search for „kotest“ on maven central. Luckily the documentation makes it clear which dependency is the right one for you. To use Kotest with your JVM project all you need is the core
module:
1 2 3 |
dependencies { testImplementation 'io.kotest:kotest-assertions-core:$version' } |
Syntax
Kotest deviates to a great degree from the standards set by AssertJ (or even Hamcrest). First it discards the well known assertThat
in favor of should
. Then the various should
matchers are not wrappers, but extension functions, so they too can be read as grammatically correct sentences in the form of x shouldBe y
. For example if we wanted to run asserts on a String
1 |
val str = cut.stringResult() // "The Perfect Flow" |
the AssertJ version would look like this
1 2 3 4 |
assertThat(str) .isNotBlank() .hasSize(16) .containsIgnoringCase("flow") |
while in Kotest things would look like that:
1 2 3 |
str.shouldNot(beBlank()) str.shouldHaveLength(16) str.shouldContainIgnoringCase("flow") |
But that is not where the changes end. As already said, the matchers are also infix
functions. As such we can pull things apart even more to the point that our code looks almost like prose:
1 2 3 |
str shouldNot beBlank() str shouldHaveLength 16 str shouldContainIgnoringCase "flow" |
There are two minor downsides to this approach. On the one hand vararg
arguments don’t work with infix functions, so when you are comparing lists you will always need an additional listOf
:
1 2 3 |
val list = cut.listResult() // [1,2,3] list shouldContainExactlyInAnyOrder listOf(1, 2, 3) |
Another point is that Kotest’s interface is not fluent. However in my opinion this is not a problem since the infix version looks cleaner anyway. And thanks to Kotlin’s scope functions tests where you need to compare multiple fields are likely to look like this:
1 2 3 4 5 6 7 |
val pojo = cut.productResult() with(pojo) { name shouldBe "The Perfect Flow" value shouldBe BigDecimal("123.5") quantity shouldBe 5 } |
Apart from that Kotest’s syntax will mostly look familiar, with profiting off Kotlin’s lambdas being a minor selling point, e.g.
1 2 3 |
val list = cut.listResult() // [1,2,3] list.forAll { it shouldBe positive() } |
or
1 2 3 4 5 |
val error = shouldThrow<ServiceException> { cut.failure() } error.message shouldBe "Error XYZ" |
Error Messages
Kotest’s error messages are generally shorter than those AssertJ and usually not divided into multiple lines. For example a failed test for a string that was expected to be blank will look like this in AssertJ:
1 2 |
java.lang.AssertionError: Expecting blank but was:<"The Perfect Flow"> |
and like this in Kotest:
1 |
java.lang.AssertionError: "The Perfect Flow" should contain only whitespace |
There are 2 issues with this approach. First is the lack of contextual information. Asking for a non-existent key in a map will print its full content in AssertJ:
1 2 3 4 5 |
java.lang.AssertionError: Expecting: <{"A"=1, "B"=2, "C"=3, "D"=4}> to contain key: <"x"> |
Kotest on the other hand simply reports that the wanted key is missing:
1 |
java.lang.AssertionError: Map should contain key x |
So if you want to know what the map actually does contain you might have no choice but run grab the debugger and run the test another time.
The second problem is that error messages can quickly swell in size because they are dealing with large objects, or lists with many elements. For example a failure in comparing a list of OffsetDateTime
objects would print each list element in its own line in AssertJ, so it’s easy to determine what went wrong:
1 2 3 4 5 6 7 8 |
java.lang.AssertionError: Expecting: <[2021-07-23T16:55:28.659428+02:00 (java.time.OffsetDateTime)]> to contain exactly in any order: <[2021-07-23T16:55:28.659428+02:00 (java.time.OffsetDateTime), 2021-07-16T16:55:28.659428+02:00 (java.time.OffsetDateTime)]> but could not find the following elements: <[2021-07-16T16:55:28.659428+02:00 (java.time.OffsetDateTime)]> |
The Kotest version puts everything onto a single line, so it’s not just difficult to read, it probably requires constant horizontal scrolling as well:
1 |
java.lang.AssertionError: Collection should contain [2021-07-23T16:53:58.195870+02:00, 2021-07-16T16:53:58.195870+02:00] in any order, but was [2021-07-23T16:53:58.195870+02:00] |
Fortunately this is not always the case, and Kotest does sometimes pull things apart for proper readability. For example asserting that all elements of a list are positive, and failing on half of them will list all successes and failures in their own lines:
1 2 3 4 5 6 7 8 9 |
java.lang.AssertionError: 2 elements passed but expected 4 The following elements passed: 1 3 The following elements failed: -2 => -2 should be > 0 -4 => -4 should be > 0 |
In particular comparisons made with shouldBe
will always put their inputs on individual lines:
1 2 3 |
org.opentest4j.AssertionFailedError: expected:<"The Perfet Flow"> but was:<"The Perfect Flow"> Expected :"The Perfet Flow" Actual :"The Perfect Flow" |
There is also the option of using Kotest’s Clues
feature, at least if you are willing to fully commit to Kotest and use it as your test framework in addition to its assertions library.
Setting up clues allows you to avoid potentially awkward and informative error messages by imbuing them with contextual information. For example if you have a failing assert that expect a value to not be null the error message will look like this:
1 |
java.lang.AssertionError: <null> should not equal <null> |
The withClue
scope function allows you to describe the assert you are making:
1 2 3 |
withClue("Name should be present") { user.name shouldNotBe null } |
so that when the assert fails it will look like this:
1 2 |
Name should be present java.lang.AssertionError: <null> should not equal <null> |
Another option is to have an entire object serve as the clue:
1 2 3 4 5 6 |
val response = HttpResponse(404, "the content") response.asClue { it.status shouldBe 200 it.body shouldBe "the content" } |
will output
1 2 3 |
HttpResponse(status=404, body=the content) Expected :200 Actual :404 |
Supported Types and Diversity of Assertions
Kotest boasts a strong selection of matchers. Apart from the bare basics like Strings, Numbers, Lists, Maps and Exceptions, it also supports less commonly used types like URIs, KClasses and BigDecimals. Kotlin-exclusives like Channels and Pairs, and Java old-timers like Dates and Optionals are covered as well.
The list of individual asserts is solid as well, it’ll probably be a while until you actually find a gap you have to fill with a custom-made assert of your own.
There are also several additional modules offering support for e.g. Json, Arrow or Ktor.
Custom Asserts
To write a custom assertion for Kotest you need to create a function that returns an instance of the Matcher<T>
interface. This interface has a single method you need to implement: fun test(value: T): MatcherResult
. For example creating an assert that an Optional
contains a specific value would look like this: (though technically this is unnecessary since Kotest natively includes a contains
matcher for Optionals)
1 2 3 4 5 6 7 8 9 10 11 |
fun <T> haveValue(content: T) = object : Matcher<Optional<T>> { override fun test(value: Optional<T>) = MatcherResult( passed = value.isPresent && value.get() == content, failureMessage = "Optional should have contained '$content', but " + if (value.isEmpty) { "was empty" } else { "contained '${value.get()}' instead" }, negatedFailureMessage = "Optional should not have contained '${value.get()}', but it did" ) } |
You can have both a positive and negative error message so that the new matcher can be used with both should
and shouldNot
assert:
1 2 3 4 |
val result = cut.optionalResult() result should haveValue("Foo") result shouldNot haveValue("Bar") |
giving you error messages like
1 |
java.lang.AssertionError: Optional should have contained 'XYZ', but contained 'ABC' instead |
and
1 |
java.lang.AssertionError: Optional should not have contained 'ABC', but it did |
In addition you can create a proper infix function for the matcher so that you don’t need the should
and shouldNot
go-betweens:
1 2 3 4 5 6 7 |
infix fun <T> Optional<T>.shouldHaveValue(value: T) = this should haveValue(value) infix fun <T> Optional<T>.shouldNotHaveValue(value: T) = this shouldNot haveValue(value) val result = cut.optionalResult() result shouldHaveValue "Foo" result shouldNotHaveValue "Bar" |
Quality of Documentation
Kotest’s Kdoc is somewhat unevenly distributed. Many of the more „obvious“ asserts (like shouldHaveSize
) are not documented at all. However the Kdoc that does exist is quite thorough, containing both clear explanations, oftentimes good examples, and links to related asserts. So while it would have been nice for the docs to be this good consistently, in practice it is unlikely to be a problem.
Outside the IDE there is also Kotest’s online documentation serving as a thorough guide and reference to all of its features, like the list of all of its matchers, or its support for non-deterministic testing.
Active Development
At the time this blog is written Kotest shows a consistent and healthy number of pushed commits and accepted pull requests. In addition it is maintained by an organization with multiple members, so at least in theory it can boast a bus factor greater than one.
— Introduction — AssertJ — Strikt — AssertK — KoTest Assertions — Atrium — Kluent — Conclusions —
Aktuelle Beiträge






Artikel kommentieren