PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 2 min read

Kotlin Testing with Kotest & MockK — Beyond JUnit

Level up your Kotlin testing — Kotest for expressive test styles and assertions, MockK for idiomatic mocking. A practical guide with real-world examples.


JUnit works fine for Kotlin. But it was built for Java — annotations for everything, limited assertion messages, verbose mocking with Mockito.

Kotest and MockK are built for Kotlin. Kotest gives you expressive test styles (BDD, spec-based), powerful assertions, and property-based testing. MockK gives you Kotlin-idiomatic mocking — coroutines, extension functions, object mocks, all built-in.

Kotest setup

dependencies {
    testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
    testImplementation("io.kotest:kotest-assertions-core:5.9.1")
    testImplementation("io.kotest:kotest-property:5.9.1") // property testing
    testImplementation("io.mockk:mockk:1.14.0")
}

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

Kotest runs on the JUnit Platform, so it works with existing CI/CD, IDE test runners, and Gradle.

Test styles

Kotest offers multiple test styles. Pick the one that fits your team:

StringSpec — Minimal

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class CalculatorTest : StringSpec({
    "addition works" {
        Calculator.add(2, 3) shouldBe 5
    }

    "division by zero throws" {
        shouldThrow<ArithmeticException> {
            Calculator.divide(10, 0)
        }
    }
})

Each string is a test name. No @Test annotation, no fun keyword.

FunSpec — JUnit-like

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class UserServiceTest : FunSpec({
    test("creating a user returns the saved user") {
        val user = userService.create("Alice", "[email protected]")
        user.name shouldBe "Alice"
        user.email shouldBe "[email protected]"
    }

    test("duplicate email throws") {
        userService.create("Alice", "[email protected]")
        shouldThrow<DuplicateEmailException> {
            userService.create("Bob", "[email protected]")
        }
    }
})

BehaviorSpec — BDD style

import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize

class OrderServiceTest : BehaviorSpec({
    Given("a customer with items in cart") {
        val cart = Cart(items = listOf(
            CartItem("item-1", quantity = 2, price = 10.0),
            CartItem("item-2", quantity = 1, price = 25.0)
        ))

        When("placing an order") {
            val order = orderService.placeOrder(cart, customerId = "cust-1")

            Then("order is created with correct total") {
                order.total shouldBe 45.0
            }

            Then("order has all items") {
                order.items shouldHaveSize 2
            }

            Then("order status is PENDING") {
                order.status shouldBe OrderStatus.PENDING
            }
        }
    }

    Given("an empty cart") {
        val cart = Cart(items = emptyList())

        When("placing an order") {
            Then("it throws an exception") {
                shouldThrow<EmptyCartException> {
                    orderService.placeOrder(cart, customerId = "cust-1")
                }
            }
        }
    }
})

DescribeSpec — RSpec/Jest style

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe

class UserValidationTest : DescribeSpec({
    describe("email validation") {
        it("accepts valid email") {
            validateEmail("[email protected]") shouldBe true
        }

        it("rejects email without @") {
            validateEmail("userexample.com") shouldBe false
        }

        it("rejects email without domain") {
            validateEmail("user@") shouldBe false
        }
    }

    describe("password validation") {
        it("requires minimum 8 characters") {
            validatePassword("short") shouldBe false
            validatePassword("longenough123") shouldBe true
        }
    }
})

Pick the style that reads best for your team. FunSpec for Java teams transitioning. BehaviorSpec for BDD. DescribeSpec for frontend developers used to Jest.

Kotest assertions

Kotest assertions are more readable than JUnit’s assertEquals:

import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldStartWith
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldBeSorted
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull

// Basic
result shouldBe 42
result shouldNotBe null

// Strings
name shouldContain "Alice"
url shouldStartWith "https://"

// Collections
users shouldHaveSize 3
users shouldContain alice
users.map { it.age } shouldBeSorted()

// Nulls
optionalValue.shouldBeNull()
requiredValue.shouldNotBeNull()

// Exceptions
shouldThrow<IllegalArgumentException> {
    validateAge(-1)
}.message shouldContain "negative"

The assertion reads left-to-right: result shouldBe 42. Error messages are clear:

expected:<42> but was:<41>

MockK — Kotlin-first mocking

MockK is designed for Kotlin. It supports coroutines, extension functions, objects, and companion objects out of the box.

Basic mocking

import io.mockk.mockk
import io.mockk.every
import io.mockk.verify

class UserServiceTest : FunSpec({
    val repository = mockk<UserRepository>()
    val service = UserService(repository)

    test("getUser returns user from repository") {
        val expectedUser = User("1", "Alice", "[email protected]")
        every { repository.findById("1") } returns expectedUser

        val result = service.getUser("1")

        result shouldBe expectedUser
        verify(exactly = 1) { repository.findById("1") }
    }

    test("getUser throws when not found") {
        every { repository.findById("999") } returns null

        shouldThrow<UserNotFoundException> {
            service.getUser("999")
        }
    }
})

Coroutine support

MockK handles suspend functions natively:

import io.mockk.coEvery
import io.mockk.coVerify

class OrderServiceTest : FunSpec({
    val orderRepo = mockk<OrderRepository>()
    val paymentService = mockk<PaymentService>()
    val service = OrderService(orderRepo, paymentService)

    test("placeOrder processes payment and saves order") {
        val order = Order(id = "1", total = 50.0)
        coEvery { paymentService.charge(any(), any()) } returns PaymentResult.Success
        coEvery { orderRepo.save(any()) } returns order

        val result = service.placeOrder(order)

        result.status shouldBe OrderStatus.CONFIRMED
        coVerify { paymentService.charge("1", 50.0) }
        coVerify { orderRepo.save(any()) }
    }
})

coEvery and coVerify are the coroutine versions of every and verify.

Argument matchers

every { repository.findByEmail(any()) } returns user
every { repository.findByAge(range(18, 65)) } returns users
every { repository.save(match { it.name.isNotBlank() }) } returns user

// Capture arguments
val slot = slot<User>()
every { repository.save(capture(slot)) } returns user

service.createUser("Alice", "[email protected]")

slot.captured.name shouldBe "Alice"
slot.captured.email shouldBe "[email protected]"

Relaxed mocks

A relaxed mock returns default values instead of throwing:

val logger = mockk<Logger>(relaxed = true)
// All calls return defaults (empty string, 0, false, etc.)
// No need to set up every call

Useful for dependencies you don’t care about in a specific test (logging, analytics).

Object and companion mocking

// Mock an object
import io.mockk.mockkObject
import io.mockk.unmockkObject

mockkObject(DateUtils)
every { DateUtils.now() } returns fixedDate

// Mock a companion
import io.mockk.mockkStatic

mockkStatic(UUID::class)
every { UUID.randomUUID().toString() } returns "fixed-uuid"

Always unmockkObject / unmockkStatic in cleanup to avoid test pollution.

Combining Kotest + MockK

class ArticleServiceTest : BehaviorSpec({
    val repository = mockk<ArticleRepository>()
    val searchIndex = mockk<SearchIndex>(relaxed = true)
    val service = ArticleService(repository, searchIndex)

    Given("articles exist in the repository") {
        val articles = listOf(
            Article("1", "Kotlin Basics", "kotlin"),
            Article("2", "Spring Boot Guide", "spring"),
            Article("3", "Kotlin Coroutines", "kotlin")
        )
        coEvery { repository.getAll() } returns articles

        When("filtering by category") {
            val result = service.getByCategory("kotlin")

            Then("returns only matching articles") {
                result shouldHaveSize 2
                result.map { it.title } shouldContainAll listOf(
                    "Kotlin Basics", "Kotlin Coroutines"
                )
            }
        }

        When("getting article count") {
            val count = service.getCount()

            Then("returns total count") {
                count shouldBe 3
            }
        }
    }

    Given("repository is empty") {
        coEvery { repository.getAll() } returns emptyList()

        When("getting articles") {
            val result = service.getByCategory("kotlin")

            Then("returns empty list") {
                result shouldHaveSize 0
            }
        }
    }

    afterEach {
        clearMocks(repository, searchIndex)
    }
})

afterEach clears mocks between tests to prevent state leakage.

Property-based testing

Instead of writing specific test cases, describe properties that should always hold:

import io.kotest.core.spec.style.FunSpec
import io.kotest.property.forAll
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.email

class PropertyTests : FunSpec({
    test("reversing a list twice returns the original") {
        forAll<List<Int>> { list ->
            list.reversed().reversed() == list
        }
    }

    test("string length is never negative") {
        forAll(Arb.string()) { str ->
            str.length >= 0
        }
    }

    test("addition is commutative") {
        forAll(Arb.int(), Arb.int()) { a, b ->
            a + b == b + a
        }
    }

    test("email validation accepts valid emails") {
        forAll(Arb.email()) { email ->
            validateEmail(email)
        }
    }
})

Kotest generates random inputs and checks the property holds for all of them. If it fails, it reports the smallest failing input (shrinking).

Test lifecycle

class DatabaseTest : FunSpec({
    lateinit var db: TestDatabase

    beforeEach {
        db = TestDatabase.create()
        db.migrate()
    }

    afterEach {
        db.close()
    }

    test("insert and retrieve") {
        db.users.insert(User("1", "Alice"))
        val user = db.users.findById("1")
        user.shouldNotBeNull()
        user.name shouldBe "Alice"
    }
})

Lifecycle callbacks: beforeEach, afterEach, beforeSpec, afterSpec, beforeTest, afterTest.

Kotest + MockK vs JUnit + Mockito

FeatureKotest + MockKJUnit + Mockito
Test styles10+ styles (BDD, Spec, etc.)One style
AssertionsshouldBe, shouldContain, etc.assertEquals, assertTrue
Coroutine mockingBuilt-in (coEvery)Needs mockito-kotlin + hacks
Object mockingBuilt-in (mockkObject)Needs PowerMock or other tools
Property testingBuilt-in (forAll)Needs separate library
Kotlin-firstYesJava-first
IDE supportJUnit Platform (same)JUnit Platform

Both work. Kotest + MockK is more Kotlin-idiomatic. JUnit + Mockito has a larger community and more tutorials. For Kotlin-heavy projects, Kotest + MockK is the better fit.

Summary

  • Kotest — multiple test styles, readable assertions, property testing, lifecycle management
  • MockK — Kotlin-first mocking with coroutine, object, and companion support
  • Together — expressive, type-safe, Kotlin-idiomatic test suites

Start with FunSpec or DescribeSpec, use shouldBe assertions, mock with mockk / coEvery, and add property tests for core logic. Your tests will be shorter, more readable, and catch more bugs.