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
| Feature | Kotest + MockK | JUnit + Mockito |
|---|---|---|
| Test styles | 10+ styles (BDD, Spec, etc.) | One style |
| Assertions | shouldBe, shouldContain, etc. | assertEquals, assertTrue |
| Coroutine mocking | Built-in (coEvery) | Needs mockito-kotlin + hacks |
| Object mocking | Built-in (mockkObject) | Needs PowerMock or other tools |
| Property testing | Built-in (forAll) | Needs separate library |
| Kotlin-first | Yes | Java-first |
| IDE support | JUnit 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.