1. Overview

Kotest is a multi-platform testing framework written in Kotlin. It consists of 3 main subprojects:

  • Test framework
  • Assertions library
  • Property testing

We can use each project independently from other testing frameworks. For example, it is possible to use the Kotest framework instead of Jupiter with assertj assertions.

We can execute Kotest tests in JVM, Javascript or Native. This enables us to use the same testing library for backend, mobile, and web development.

In this tutorial, we’ll focus on running the tests only on the JVM platform.

2. Testing on JVM

Kotest uses the JUnit Platform on the JVM. So, in a Maven project, we can activate it using the following declarations:

<dependency>
    <groupId>io.kotest</groupId>
    <artifactId>kotest-runner-junit5-jvm</artifactId>
    <version>5.1.0</version>
    <scope>test</scope>
</dependency>

3. Testing Styles

Kotest provides many different testing styles. Let’s look at examples of some popular styles.

3.1. Behavior Spec

We can write BDD like tests in this style using given, when and then keywords:

class CardPaymentTests : BehaviorSpec({
    given("I have sufficient balance") {
        `when`("I make a card payment") {
            then("The card payment should be successful") {
                // test code
            }
        }
    }
})

3.2. Should Spec

We can create tests using the should keyword:

class MoneyTests : ShouldSpec({
    should("Convert input money to the target currency") {
        // test code
    }
})

We can group related tests in a context block:

class PaymentTests : ShouldSpec({
    context("CardPayments") {
        should("Make a card payment") {
            // test code
        }
    }
    context("BankTransfers") {
        should("Make an external bank transfer") {
            // test code
        }
    }
})

3.3. Feature Spec

Next, let’s see how can write end-to-end, cucumber like tests using feature and scenario keywords:

class HomePageTests : FeatureSpec({
    feature("signup") {
        scenario("should allow user to signup with email") {
            // test code
        }
    }
    feature("signin") {
        scenario("should allow user with valid credentials to login") {
            // test code
        }
    }
})

3.4. Describe Spec

Using describe, we can write tests in a style that’s very popular among Javascript and Ruby developers:

class PaymentTests : DescribeSpec({
    describe("CardPayments") {
        it("Should make a card payment") {
            // test code
        }
    }
    describe("BankTransfers") {
        it("Should make an external bank transfer") {
            // test code
        }
    }
})

4. Assertions

We have previously seen that Kotest has separate libraries meant for assertions. These libraries provide us with several matcher functions to write fluent assertions in our tests. There are two broad categories of assertion libraries:

  • Core matchers
  • External matchers

Let’s look at some examples of matchers in the kotest-assertions-core library:

// verify actual object is equal to expected object
result.shouldBe(expected)

// verify actual expression is true
result.shouldBeTrue()

// verify actual object is of given type
result.shouldBeTypeOf<Double>()

// verify actual map contains the given key
result.shouldContainKey(key)

// verify actual map contains the given values
result.shouldContainValues(values)

// verify actual string contains the given substring
result.shouldContain("substring")

// verify actual string is equal to the given string ignoring case
result.shouldBeEqualIgnoringCase(otherString)

// verify actual file should have the given size
result.shouldHaveFileSize(size)

// verify actual date is after the given date
result.shouldBeBefore(otherDate)

In addition to the core assertion module, there are several other modules that provide matchers for a wide variety of scenarios, e.g., JSON matchers, JDBC matches, and so on.

5. Testing Exceptions

On the other hand, testing exceptions with Kotest is very simple:

val exception = shouldThrow<ValidationException> {
   // test code
}
exception.message should startWith("Invalid input")

6. Lifecycle Hooks

We can use lifecycle hooks to set up or teardown test fixtures before or after tests. These hooks are very similar to setup and teardown methods in Junit. Let’s look at an example:

class TransactionStatementSpec : ShouldSpec({
    beforeTest {
      // add transactions
    }
    afterTest { (test, result) ->
      // delete transactions
    }
})

7. Data-Driven Tests

Data-driven tests in Kotest are similar to parameterized tests in Junit5. We can provide several inputs to a single test case to check different examples instead of writing several tests with just different input data. We can use the withData function from the kotest-framework-datatest-jvm library to supply data to tests.

Let’s see an example:

data class TaxTestData(val income: Long, val taxClass: TaxClass, val expectedTaxAmount: Long)

class IncomeTaxTests : FunSpec({
    withData(
      TaxTestData(1000, ONE, 300),
      TaxTestData(1000, TWO, 350),
      TaxTestData(1000, THREE, 200)
    ) { (income, taxClass, expectedTaxAmount) ->
        calculateTax(income, taxClass) shouldBe expectedTaxAmount
    }
})

8. Non-Deterministic Tests

Sometimes, we need to test functions that do not return results synchronously. Unfortunately, it is tricky to test such functions as we have to write custom boilerplate code to wait for results using techniques like callback functions or sleeping on the thread.

Kotest provides a few useful functions that we can use to write such non-deterministic tests in a declarative fashion.

Let’s look at an example of the eventually function:

class TransactionTests : ShouldSpec({
    val transactionRepo = TransactionRepo()

    should("Should make transaction complete") {
        eventually({
            duration = 5000
            interval = FixedInterval(1000)
        }) {
            transactionRepo.getStatus(120) shouldBe "COMPLETE"
        }
    }
})

Here, our test will check the transaction’s status every second for up to 5 seconds.

9. Mocking

We can integrate any mocking library such as mockk with Kotest. Kotest doesn’t provide its own mocking library:

class ExchangeServiceTest : FunSpec({
    val exchangeRateProvider = mockk<ExchangeRateProvider>()
    val service = ExchangeService(exchangeRateProvider)

    test("Exchanges money using rate from exchange rate service") {
        every { exchangeRateProvider.rate("USDEUR") } returns 0.9
        service.exchange(Money(1200, "USD"), "EUR") shouldBe 1080
    }
})

10. Test Coverage

We can integrate Jacoco with Kotest to measure test coverage. To integrate we need to ensure that the test coverage reports are generated after unit tests are run:

tasks.test {
    finalizedBy(tasks.jacocoTestReport)
}

We can find the test coverage HTML report inside the $buildDir/reports/jacoco/test directory.

11. Grouping Tests with Tags

Sometimes, we want to run only certain tests in some specific environment. For example, we may want to avoid running some slow tests as part of our git pull request verification builds. To do that we need to first tag our tests:

@Tags(NamedTag("SlowTest"))
class SlowTests : ShouldSpec({})

12. Conclusion

In this tutorial, we learned about several basic functionalities provided by the Kotest framework.

As usual, the code examples can be found over on GitHub.