Introduction to Kotest

Table Of Contents

Kotest is simply a multi-platform framework used for testing written in Kotlin. In this tutorial, we shall cover the following sub-topics related to the Kotest framework: testing with Kotest, testing styles used, grouping Kotest tests with tags, the lifecycle hooks and assertions supported by Kotest.

Testing with Kotest

We’re going to learn how we can test our Kotlin code using Kotest. Before writing the tests, we shall first have to add Kotest framework dependencies in our project:

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

Using Describe Spec Style

To write our tests, we normally create a Kotlin test file ending with the suffix like Test.kt. In this example, we’ll use the DescribeSpec style to define a suite of tests.

class MyTestClass: DescribeSpec() {
    init {
        describe("My test suite") {
            it("should add two numbers") {
                val result = 1 + 2
                result shouldBe 3
            }
            it("should concatenate two strings") {
                val result = "Hello, " + "World!"
                result shouldBe "Hello, World!"
            }
        }
    }
}

In this Kotest test, we first define a test class MyTestClass that uses the Kotest testing framework, specifically the DescribeSpec style. Within the class’s initializer block, we describe a test suite titled My test suite that contains two individual tests defined using the it function. The first test checks whether adding 1 and 2 results in 3, and the second test verifies that concatenating two strings Hello, and World! results in the string Hello, World!. The shouldBe function is used to assert that the actual result matches the expected value in each test.

Using Behavior Spec Style

Let’s take a look at how we can write tests using the BehaviorSpec style in Kotest:

class MyBehaviorSpec: BehaviorSpec({
    given("a calculator") {
        val calculator = Calculator()
        when("adding two numbers") {
            val result = calculator.add(2, 3)
            then("it should return the correct sum") {
                result shouldBe 5
            }
        }
        when("subtracting two numbers") {
            val result = calculator.subtract(5, 2)
            then("it should return the correct difference") {
                result shouldBe 3
            }
        }
    }
})

In our example, we have a given block that sets up a context, a when block that represents an action, and a then block that contains assertions about the expected behavior.

Using Should Spec Style

While using the ShouldSpec block, we use the should keyword to define our test cases and describe the expected behavior of our code:

class MyShouldSpec: ShouldSpec({
    should("return the correct sum when adding two numbers") {
        val result = Calculator().add(2, 3)
        result shouldBe 5
    }
    should("return the correct difference when subtracting two numbers") {
        val result = Calculator().subtract(5, 2)
        result shouldBe 3
    }
})

Using Feature Spec Style

We shall use the feature and scenario functions to define features and scenarios respectively. Our feature represents a higher-level feature, and scenarios describe specific behaviors or test cases within our feature.

Example code using Feature Spec would be:

class MyFeatureSpec : FeatureSpec({
    feature("Calculator") {
        scenario("addition") {
            val result = Calculator().add(2, 3)
            then("it should return the correct sum") {
                result shouldBe 5
            }
        }
        scenario("subtraction") {
            val result = Calculator().subtract(5, 2)
            then("it should return the correct difference") {
                result shouldBe 3
            }
        }
    }
    feature("String Manipulation") {
        scenario("concatenation") {
            val result = StringUtil.concat("Hello", "World")
            then("it should concatenate strings correctly") {
                result shouldBe "HelloWorld"
            }
        }
        scenario("length") {
            val result = StringUtil.getLength("Kotlin")
            then("it should return the correct length") {
                result shouldBe 6
            }
        }
    }
})

In our example, we’ve defined two features Calculator and String Manipulation, each containing scenarios that describe specific behaviors.

JUnit vs Kotest

Choosing between Kotest and JUnit frameworks for a Kotlin project depends on our specific project requirements and preferences. Both Kotest and JUnit frameworks have their own advantages and may be better suited for different use cases. Here are some reasons why we might consider using Kotest over JUnit in a Kotlin project:

Kotlin native support: Kotest is designed with Kotlin and provides native support for Kotlin features, such as coroutines, property-based testing, and DSLs, making it more natural to work with Kotlin codebases.

Rich assertion library: Kotest comes with a powerful and extensible assertion library that allows developers to write expressive and concise assertions in a Kotlin idiomatic style. It provides a wide range of assertion functions that can make our tests more readable and maintainable.

Property-Based testing: Kotest supports property-based testing, which allows us to define properties that our code should satisfy, and then it generates test cases to check those properties.This can help us discover edge cases and unexpected behavior in our code.

Test configuration and hooks: Kotest provides flexible ways to configure our test suite and define test lifecycle hooks. We can also set up custom behavior before and after tests, which can be useful for tasks like database setup and teardown.

Concurrent testing: Kotest offers built-in support for running tests concurrently, which can significantly speed up test execution, especially in projects with a large number of tests.

Test case nesting: Kotest allows us to nest test cases and groups, which helps us to organize our tests more hierarchically and logically, making it easier to manage complex test suites.

Integration with other libraries: Kotest integrates well with other libraries and frameworks commonly used in Kotlin projects, such as MockK for mocking, Koin for dependency injection, and kotlinx.coroutines for coroutine testing.

Kotest Assertions

The Kotest framework provides us with several matcher functions that help us write fluent assertions in our tests. These matchers are designed to help us verify various conditions and expectations in a concise and readable manner.

Here are some of the commonly used Kotest matchers:

Assertion Description
@shouldBe Asserts that value should be the expected value
@shouldNotBe Asserts that value should not be the expectedValue
@shouldBeLessThan Asserts that value should be less than the max value
@shouldBeLessThanOrEqual Asserts that value should be less than or equal to the max value
@shouldBeGreaterThan Asserts that value should be greater than minValue
@shouldBeGreaterThanOrEqual Asserts that value should be greater than or equal to the minValue
@shouldBeNull Asserts that value should be null
@shouldNotBeNull Asserts that value value should not be null
@shouldBeInstanceOf Asserts that value should be an instance of String::class
@shouldNotBeInstanceOf Asserts that value value should not be an instance of Int::class
@shouldBeOfType This matcher is used to check if an object is of a specific type and optionally matches its properties
@shouldContain Asserts that a collection should contain an element
@shouldNotContain Asserts that a collection should not contain an element
@shouldHaveSize Asserts that a collection should have a size as the expectedSize
@shouldNotContain Asserts that a collection should be empty
@shouldNotBeEmpty Asserts that a string should not be empty
@shouldStartWith Asserts that a string should start with “should_start_with”
@shouldEndWith Asserts that a string should end with “should_end_with”
@shouldContainSubstring This matcher checks if a string contains a specific substring
@shouldThrow This matcher is used to check if a specific exception is thrown during the execution of a block of code
@shouldNotThrow This matcher checks that a block of code does not throw an exception

Lifecycle Hooks

In the Kotest framework, we can use lifecycle hooks to perform setup and teardown operations before and after our tests. Generally, these hooks allow us to prepare our test environment, set up resources and finally clean up after our tests have been executed.

Here are the most commonly used lifecycle hooks in Kotest:

Hook Description
@beforeSpec This hook runs once before all the tests in spec
@afterSpec This hook runs once after all the tests in spec
@beforeTest This hook runs before each individual test within a spec
@afterTest This hook runs after each individual test within a spec
@beforeContainer This hook runs before each nested container within a spec
@afterContainer This hook runs after each nested container within a spec

Data-Driven Testing in Kotest

Data-driven testing is a testing approach where we parameterize our tests with different sets of data, allowing us to run the same test logic with multiple input values to ensure that our code works correctly in various scenarios.

In order to achieve data-driven testing in Kotest, we use the withDatafunction.

Let’s see an example of this concept:

data class Car(val make: String, val model: String, val year: Int, val expectedPrice: Int)

class CarPricingTests : FunSpec({
    withData(
        Car("Toyota", "Camry", 2020, 25000),
        Car("Honda", "Civic", 2021, 22000),
        Car("Ford", "Focus", 2019, 18000)
    ) { (make, model, year, expectedPrice) ->
        val actualPrice = calculateCarPrice(make, model, year)
        actualPrice shouldBe expectedPrice
    }
})

fun calculateCarPrice(make: String, model: String, year: Int): Int {
    return when (make) {
        "Toyota" -> 25000
        "Honda" -> 22000
        "Ford" -> 18000
        else -> 0 
    }
}

In this example, Car is a data class representing the input data for our tests. It includes properties for make, model, year, and expectedPrice. Inside the CarPricingTests test class, we are using the withData function to define sets of input data as instances of the Car data class. For each set of input data, a test is created. This test invokes the calculateCarPrice function with the provided input values (make, model, and year) and checks if the result matches the expectedPrice.

Grouping Kotest Tests with Tags

Another amazing feature of Kotest is its ability to group tests with tags. This allows us to categorize our tests and run specific groups of tests based on these tags. Tags are helpful for organizing our test suite, especially when we have a large number of tests and want to run only a subset of them, such as smoke tests, regression tests, or tests for a specific module of our application.

To tag our tests, we can use the Tags annotation provided by Kotest:

@Tags("smoke", "regression")
class MyTestSuite : FunSpec({
    test("Test case 1") {
        // Test logic here
    }

    test("Test case 2") {
        // Test logic here
    }
})

In this example, the MyTestSuite test class is tagged with both smoke and regression tags.

To run tests based on our tags, we can use the --kotest.tags command-line option when executing our test suite.

./gradlew test --tests * --kotest.tags="smoke"

Conclusion

In this tutorial, we have gone through the Kotest framework, its various testing styles, how we can group Kotest tests using tags and the various assertions Kotest supports inclusive of the lifecycle hooks.

Written By:

Ezra Kanake

Written By:

Ezra Kanake

Ezra is a passionate Kotlin developer and technical writer. He loves working on open-source projects and sharing knowledge across the globe.

Recent Posts

Guide to JUnit 5 Functional Interfaces

In this article, we will get familiar with JUnit 5 functional interfaces. JUnit 5 significantly advanced from its predecessors. Features like functional interfaces can greatly simplify our work once we grasp their functionality.

Read more

Getting Started with Spring Security and JWT

Spring Security provides a comprehensive set of security features for Java applications, covering authentication, authorization, session management, and protection against common security threats such as CSRF (Cross-Site Request Forgery).

Read more

Creating and Publishing an NPM Package with Automated Versioning and Deployment

In this step-by-step guide, we’ll create, publish, and manage an NPM package using TypeScript for better code readability and scalability. We’ll write test cases with Jest and automate our NPM package versioning and publishing process using Changesets and GitHub Actions.

Read more