Skip to content

Test-Driven Development with Kotest — Complete Kotlin Testing Guide

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you will learn about Test. We cover key concepts, practical examples, and best practices to help you master this topic.

Kotest is a powerful Kotlin-native testing framework that supports multiple testing styles, property-based testing, coroutine testing, and seamless integration with mocking libraries, enabling you to practice Test-Driven Development (TDD) with idiomatic Kotlin code.

What You Will Learn

  • Setting up Kotest in a Kotlin project with Gradle
  • Writing tests using String Spec, Fun Spec, and Behavior Spec styles
  • Applying the TDD cycle: Red-Green-Refactor
  • Using property-based testing with generators
  • Testing coroutines and flows with Kotest's coroutine test utilities
  • Mocking dependencies with MockK and Kotest
  • Writing data-driven tests with tables

Why It Matters

Test-Driven Development is a discipline where you write a failing test before writing the production code. This approach forces you to think about the Api Design upfront, leads to more testable code, and reduces debugging time. Kotest supports TDD better than JUnit for Kotlin projects because it offers Kotlin-specific testing styles, built-in property-based testing, and coroutine support. When you adopt TDD with Kotest, you write tests that are more readable, more expressive, and more aligned with how Kotlin code is written.

Real-World Use

The DodaTech backend team uses TDD with Kotest for all API development. Before implementing a new endpoint, a test is written that defines the expected behavior. The test initially fails, the implementation is built to make it pass, and then the code is refactored. This practice has reduced production bugs by 60 percent and made the codebase easier to maintain across team changes.

Learning Path

flowchart LR
  A[Kotlin Testing Basics] --> B[TDD with Kotest\nYou are here]
  B --> C[Dependency Injection + Spring Boot]
  style B fill:#f90,color:#fff

Setting Up Kotest

Add the Kotest dependencies to your build.gradle.kts:

// build.gradle.kts
dependencies {
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
    testImplementation("io.kotest:kotest-assertions-core:5.8.0")
    testImplementation("io.kotest:kotest-property:5.8.0")
    testImplementation("io.kotest:kotest-framework-datatest:5.8.0")
    testImplementation("io.mockk:mockk:1.13.9")
}

Kotest uses the JUnit Platform runner under the hood, so it integrates with any build tool that supports JUnit 5. The kotest-runner-junit5 dependency registers the Kotest engine with the JUnit Platform.

The TDD Cycle

TDD follows three steps:

  1. Red: Write a test that defines the desired behavior. Run it and watch it fail because the production code does not exist yet.
  2. Green: Write the minimum production code needed to make the test pass.
  3. Refactor: Improve the code without changing its behavior, keeping the tests green.

Repeat this cycle for each new feature. The tests become a safety net that lets you refactor with confidence.

Writing Your First Kotest: String Spec

Kotest supports multiple testing styles. StringSpec uses string literals for test names, which makes tests read like documentation:

// src/test/kotlin/com/dodatech/calculator/CalculatorTest.kt
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class CalculatorTest : StringSpec({
    "adding two numbers returns their sum" {
        val calculator = Calculator()
        val result = calculator.add(2, 3)
        result shouldBe 5
    }

    "subtracting a larger number from a smaller returns negative" {
        val calculator = Calculator()
        val result = calculator.subtract(3, 7)
        result shouldBe -4
    }
})

The test body is a lambda passed to the StringSpec constructor. shouldBe is a Kotest infix assertion that reads naturally. If the assertion fails, Kotest provides a detailed error message showing the expected and actual values.

Implementing the Calculator (Green Phase)

Write the minimum code to pass the tests:

// src/main/kotlin/com/dodatech/calculator/Calculator.kt
class Calculator {
    fun add(a: Int, b: Int): Int = a + b
    fun subtract(a: Int, b: Int): Int = a - b
}

Run the tests. They pass. Now you have a safety net. You can refactor safely knowing that if you break something, the tests will tell you immediately.

Behavior Spec for BDD-Style Tests

BehaviorSpec supports Given-When-Then style tests, which are ideal for acceptance criteria:

// src/test/kotlin/com/dodatech/calculator/CalculatorBehaviorTest.kt
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe

class CalculatorBehaviorTest : BehaviorSpec({
    given("a calculator") {
        val calculator = Calculator()
        `when`("adding two positive numbers") {
            val result = calculator.add(10, 20)
            then("the result should be the sum") {
                result shouldBe 30
            }
        }
        `when`("subtracting a number from itself") {
            val result = calculator.subtract(7, 7)
            then("the result should be zero") {
                result shouldBe 0
            }
        }
    }
})

Backticks around when and then are required because when is a Kotlin reserved keyword. BDD-style tests map directly to user stories and make it easy for non-technical stakeholders to understand the expected behavior.

TDD in Action: Building a Shopping Cart

Let us walk through a complete TDD cycle for a ShoppingCart class. We start with a test:

// RED: Write a failing test
class ShoppingCartTest : StringSpec({
    "empty cart has total of zero" {
        val cart = ShoppingCart()
        cart.total() shouldBe 0.0
    }
})

The test does not compile because ShoppingCart does not exist. Create the class with the minimal implementation:

// GREEN: Make the test pass
class ShoppingCart {
    fun total(): Double = 0.0
}

The test passes. Now add a test for adding items:

// RED: Test for adding items
class ShoppingCartTest : StringSpec({
    "empty cart has total of zero" {
        val cart = ShoppingCart()
        cart.total() shouldBe 0.0
    }

    "cart with one item has that item's price as total" {
        val cart = ShoppingCart()
        cart.addItem("Apple", 1.50)
        cart.total() shouldBe 1.50
    }
})

This test will fail because addItem does not exist. Implement it:

// GREEN: Make the new test pass
class ShoppingCart {
    private val items = mutableListOf<Double>()

    fun addItem(name: String, price: Double) {
        items.add(price)
    }

    fun total(): Double = items.sum()
}

The test passes. Continue with more tests: multiple items, removing items, applying discounts. Each cycle adds a small piece of functionality and keeps all existing tests passing.

Property-Based Testing

Property-based testing generates random inputs and verifies that certain properties hold for all of them. Kotest's forAll function tests a property with automatically generated values:

import io.kotest.property.forAll
import io.kotest.property.arbitrary.int

class PropertyBasedTest : StringSpec({
    "addition is commutative" {
        forAll<Int, Int> { a, b ->
            val calc = Calculator()
            calc.add(a, b) == calc.add(b, a)
        }
    }

    "adding zero returns the original number" {
        forAll<Int> { a ->
            val calc = Calculator()
            calc.add(a, 0) == a
        }
    }
})

Property-based testing catches edge cases you would not think to test manually. What happens when a is Int.MAX_VALUE and b is 1? The commutative property test will generate that combination and reveal the overflow bug.

Custom Generators

You can create custom generators for domain types:

import io.kotest.property.Arb
import io.kotest.property.arbitrary.map
import io.kotest.property.arbitrary.nextString

data class User(val name: String, val age: Int)

val userGenerator = Arb.map(
    Arb.nextString(lengthRange = 1..20),
    Arb.int(0..120)
) { name, age -> User(name, age) }

Use userGenerator with forAll to test functions that operate on users with randomly generated data.

Data-Driven Tests with Tables

Kotest's table extension lets you test multiple input combinations declaratively:

import io.kotest.assertions.fail
import io.kotest.core.spec.style.StringSpec
import io.kotest.datatest.withData

class DataDrivenTest : StringSpec({
    "valid emails pass validation" {
        val validator = EmailValidator()
        withData(
            listOf(
                "user@example.com",
                "alice@dodatech.com",
                "bob@gmail.co.uk",
                "user+tag@domain.org"
            )
        ) { email ->
            validator.isValid(email) shouldBe true
        }
    }

    "invalid emails fail validation" {
        val validator = EmailValidator()
        withData(
            listOf(
                "not-an-email",
                "@missing-username.com",
                "user@",
                "user@.com"
            )
        ) { email ->
            validator.isValid(email) shouldBe false
        }
    }
})

Data-driven tests eliminate repetitive code. When a data point fails, Kotest reports which specific input caused the failure.

Testing Coroutines

Kotest integrates with kotlinx-coroutines-test to provide WithTest for testing suspend functions:

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.*

class CoroutineTest : StringSpec({
    "suspend function returns correct value" {
        val result = withTestScope {
            val repo = WeatherRepository(FakeWeatherApi())
            repo.fetchWeather("London")
        }
        result shouldBe Weather("London", 20.0)
    }

    "flow emits expected values" {
        val flow = flow {
            delay(100)
            emit("A")
            delay(100)
            emit("B")
        }
        withTestScope {
            val results = mutableListOf<String>()
            flow.toList(results)
            results shouldBe listOf("A", "B")
        }
    }
})

withTestScope provides a virtual time environment where delay advances time instantly instead of waiting. Tests that involve coroutines run in milliseconds instead of seconds.

Mocking with MockK

MockK is a Kotlin-first mocking library that pairs well with Kotest:

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest

class OrderServiceTest : StringSpec({
    "processOrder sends confirmation email" {
        val emailService = mockk<EmailService>()
        coEvery { emailService.sendEmail(any(), any()) } returns Unit

        val orderService = OrderService(emailService)
        val result = orderService.processOrder(Order("123", "alice@test.com"))

        result shouldBe true
    }
})

coEvery is used for mocking suspend functions. MockK provides every for regular functions, verify for checking call counts, and slot for capturing arguments.

Test Organization Best Practices

Organize tests by feature rather than by type:

src/test/kotlin/com/dodatech/
├── shopping/
│   ├── ShoppingCartTest.kt
│   └── CheckoutServiceTest.kt
├── auth/
│   ├── LoginUseCaseTest.kt
│   └── TokenValidatorTest.kt
└── shared/
    ├── EmailValidatorTest.kt
    └── CurrencyConverterTest.kt

Use nested test classes with describe for grouping related tests. Name tests as complete sentences describing the expected behavior.

Running Tests

Run all tests with Gradle:

./gradlew test

Kotest outputs colored, hierarchical results showing which tests passed and which failed, with detailed failure messages.

Output:

CalculatorTest:
  - adding two numbers returns their sum (PASS)
  - subtracting a larger number from smaller returns negative (PASS)
ShoppingCartTest:
  - empty cart has total of zero (PASS)
  - cart with one item has that item's price as total (PASS)

Common Mistakes

  1. Writing tests after code instead of before: The full benefit of TDD comes from writing the test before the implementation. Writing tests after often results in tests that pass against broken code because they mirror the implementation rather than the specification.

  2. Testing implementation details instead of behavior: Tests should verify what the code does, not how it does it. Testing private methods or internal state makes tests brittle and hinders Refactoring.

  3. Using Thread.sleep in coroutine tests: delay inside withTestScope advances virtual time instantly. Using Thread.sleep defeats virtual time and makes tests slow and flaky.

  4. Ignoring test failures: The TDD rule is that failing tests must be fixed immediately. Accumulating failing tests reduces confidence in the test suite and encourages developers to ignore it.

  5. Not testing error cases: Happy-path tests verify expected behavior, but error-path tests verify how the system handles failures. Both are equally important in production systems.

Practice Questions

  1. What is the difference between StringSpec and BehaviorSpec in Kotest, and when would you use each?
  2. How does property-based testing in Kotest differ from traditional example-based testing?
  3. Why does the TDD cycle specify writing the test before the production code?
  4. How do you test a StateFlow emission sequence in Kotest?
  5. Challenge: Implement a PasswordValidator class that checks minimum length, contains uppercase, contains digit, and contains special character. Use TDD to build it — write each test before the implementation. Use data-driven tests for the invalid cases.

Mini Project

Build a temperature converter (Celsius to Fahrenheit and vice versa) using TDD:

  • Write tests first for both conversion directions
  • Add property-based tests verifying roundtrip conversion
  • Test edge cases: absolute zero, boiling point, freezing point
  • Add a HistoryService that records conversions and test it with MockK
  • Organize tests using BehaviorSpec for readability

FAQ

How does Kotest compare to JUnit 5?

Kotest runs on the JUnit Platform, so it integrates with the same build tools and CI pipelines. Kotest offers Kotlin-specific features like multiple spec styles, property-based testing, and coroutine testing that JUnit does not provide natively.

Can I use JUnit assertions with Kotest?

Yes. Kotest does not enforce its assertion library. You can mix JUnit assertions with Kotest's shouldBe matchers, though consistent use of Kotest matchers is recommended for better failure messages.

Does Kotest support parallel test execution?

Yes. Kotest supports parallel execution at the spec level and the test level. Configure concurrency in the project configuration to control the number of parallel threads.

How do I test Android-specific code with Kotest?

Use AndroidJUnit4 runner with Kotest's annotations for instrumented tests. For unit tests, keep Android dependencies mocked or use Robolectric for framework mocking.

What is the best way to organize Kotest tests in a large project?

Group tests by feature or module, use nested spec classes for hierarchical organization, and follow consistent naming conventions that describe the behavior being tested.

What is Next

Now that you have mastered TDD with Kotest, apply these skills to real projects. Learn Dependency Injection with Koin to write testable code with clean Separation Of Concerns. Explore Spring Boot with Kotlin to build production-grade services with comprehensive test coverage. You can also study Integration Testing with Ktor for testing HTTP APIs.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro