Kotlin Testing with JUnit 5 and MockK Explained
Bob's creed: untested code is unverified assumptions. JUnit 5 + MockK is the golden Kotlin testing combo — MockK is natively designed for Kotlin (supporting coroutine mocking), and JUnit 5 provides a modern testing framework.
1. What You'll Learn
- JUnit 5 + Kotlin:
@Test,@ParameterizedTest,@Nestedtests - MockK: Kotlin-native mocking framework
- Coroutine testing:
runTest/coEvery/coVerify - Test lifecycle:
@BeforeEach/@AfterEach - Charlie in action: OrderProcessorTest full coverage
2. A QA Lead's Real Story
(1) Pain Point: Mockito Incompatibility with Kotlin
Bob frequently hit Final class errors when mocking Kotlin classes with Mockito (Kotlin classes are final by default), requiring mock-maker-inline extension configuration. And suspend functions were completely impossible to mock.
(2) MockK's Solution
KOTLIN
// Mockito: problems with final classes and suspend functions
when(repo.findById("ORD-001")).thenReturn(order) // Fails on final class!
// MockK: Kotlin native, supports everything
every { repo.findById("ORD-001") } returns order // Works on any class!
coEvery { repo.fetchOrderAsync("ORD-001") } returns order // suspend functions!
MockK is natively designed for Kotlin — no extra configuration needed, with full support for final classes, extension functions, and coroutines.
3. JUnit 5 Basics
(1) Dependency Configuration
KOTLIN
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
tasks.test {
useJUnitPlatform()
}
(2) Basic Tests
KOTLIN
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class OrderTest {
@Test
fun `should create order with correct id`() {
val order = Order("ORD-001", 299.99, "PENDING")
assertEquals("ORD-001", order.id)
assertEquals(299.99, order.total)
assertEquals("PENDING", order.status)
}
@Test
fun `should throw on negative total`() {
assertThrows(IllegalArgumentException::class.java) {
Order("ORD-001", -50.0, "PENDING")
}
}
}
(3) Parameterized Tests
KOTLIN
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class OrderPriorityTest {
@ParameterizedTest
@CsvSource(
"100.0, LOW",
"1500.0, MEDIUM",
"15000.0, HIGH"
)
fun `should determine priority based on total`(total: Double, expected: String) {
val priority = when {
total > 10_000 -> "HIGH"
total > 1_000 -> "MEDIUM"
else -> "LOW"
}
assertEquals(expected, priority)
}
}
(4) Nested Tests
KOTLIN
class OrderProcessorTest {
@Nested
inner class OrderCreation {
@Test
fun `should create pending order`() { ... }
@Test
fun `should reject negative total`() { ... }
}
@Nested
inner class OrderProcessing {
@Test
fun `should confirm pending order`() { ... }
@Test
fun `should reject confirming shipped order`() { ... }
}
}
4. MockK Basics
(1) Mock Creation and Stubbing
KOTLIN
import io.mockk.*
class OrderServiceTest {
// Create mock
val repo = mockk<OrderRepository>()
val service = OrderService(repo)
@Test
fun `should find order by id`() {
// Stub: define behavior
every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")
every { repo.findById(any()) } returns null
// Act
val order = service.findOrder("ORD-001")
// Assert
assertEquals(299.99, order?.total)
// Verify: check interaction
verify(exactly = 1) { repo.findById("ORD-001") }
}
}
(2) MockK Common API
| API | Purpose | Example |
|---|---|---|
every { } returns |
Stub return value | every { repo.findById(any()) } returns order |
every { } throws |
Stub exception | every { repo.save(any()) } throws SQLException() |
every { } answers |
Dynamic calculation | every { repo.findById(any()) } answers { ... } |
verify { } |
Verify call occurred | verify { repo.save(order) } |
verifySequence { } |
Verify call order | verifySequence { f1(); f2() } |
confirmVerified(repo) |
Confirm no unverified calls | confirmVerified(repo) |
coEvery { } returns |
Coroutine stub | coEvery { repo.fetchAsync(any()) } returns order |
coVerify { } |
Coroutine verify | coVerify { repo.fetchAsync(id) } |
(3) MockK vs Mockito
| Dimension | Mockito | MockK |
|---|---|---|
| Kotlin final classes | Requires extra config | Native support |
| Coroutine mocking | Not supported | coEvery / coVerify |
| Extension function mocking | Not supported | mockkStatic |
| Object mocking | Difficult | mockkObject |
| Syntax | Java-style | Kotlin DSL-style |
5. Coroutine Testing
(1) runTest
KOTLIN
import kotlinx.coroutines.test.runTest
class OrderServiceTest {
val repo = mockk<OrderRepository>()
val service = OrderService(repo)
@Test
fun `should fetch order asynchronously`() = runTest {
// Given
coEvery { repo.fetchOrder("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")
// When
val order = service.fetchOrderAsync("ORD-001")
// Then
assertEquals(299.99, order.total)
coVerify { repo.fetchOrder("ORD-001") }
}
}
(2) Coroutine Testing Tools
| Tool | Purpose |
|---|---|
runTest |
Coroutine test entry point (replaces runBlocking) |
coEvery |
Stub coroutine functions |
coVerify |
Verify coroutine function calls |
TestDispatcher |
Control coroutine dispatching (virtual time) |
advanceUntilIdle() |
Advance all pending coroutines |
6. Test Lifecycle
KOTLIN
class OrderServiceTest {
private lateinit var repo: OrderRepository
private lateinit var service: OrderService
@BeforeEach
fun setup() {
repo = mockk()
service = OrderService(repo)
}
@AfterEach
fun cleanup() {
unmockkAll() // Clear all mocks
}
@Test
fun `should process order`() { ... }
}
(1) Lifecycle Annotations
| Annotation | Execution Timing | Purpose |
|---|---|---|
@BeforeEach |
Before each test | Initialize mocks and test objects |
@AfterEach |
After each test | Clean up resources |
@BeforeAll |
Before all tests (needs companion object) | Expensive one-time initialization |
@AfterAll |
After all tests | Global cleanup |
7. Test Pyramid and Flow
flowchart TD
A[Unit Tests<br/>MockK + JUnit5<br/>Fast, isolated] --> B[Integration Tests<br/>Spring Boot Test<br/>Real DB/HTTP]
B --> C[E2E Tests<br/>TestContainers<br/>Full stack]
A --> D[runTest for coroutines]
A --> E[MockK for dependencies]
D --> F[Virtual time control]
8. Complete Example: OrderProcessorTest
KOTLIN
// ============================================
// OrderProcessor - Test Suite
// Feature: Full test coverage with JUnit5 + MockK
// ============================================
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
import io.mockk.*
import kotlinx.coroutines.test.runTest
// Domain classes
data class Order(val id: String, val total: Double, var status: String, val customerId: String)
interface OrderRepository {
fun findById(id: String): Order?
fun save(order: Order)
suspend fun fetchOrderAsync(id: String): Order
}
class OrderService(private val repo: OrderRepository) {
fun findOrder(id: String): Order = repo.findById(id) ?: throw NoSuchElementException("Order $id not found")
fun processOrder(id: String): Order {
val order = findOrder(id)
if (order.status != "PENDING") throw IllegalStateException("Order $id is not pending")
order.status = "CONFIRMED"
repo.save(order)
return order
}
suspend fun fetchOrderAsync(id: String): Order = repo.fetchOrderAsync(id)
fun calculatePriority(order: Order): String = when {
order.total > 10_000 -> "HIGH"
order.total > 1_000 -> "MEDIUM"
else -> "LOW"
}
}
class OrderServiceTest {
private lateinit var repo: OrderRepository
private lateinit var service: OrderService
@BeforeEach
fun setup() {
repo = mockk(relaxed = true) // Relaxed: returns default values for unstubbed methods
service = OrderService(repo)
}
@AfterEach
fun cleanup() {
unmockkAll()
}
@Nested
@DisplayName("Order Retrieval")
inner class OrderRetrieval {
@Test
fun `should find existing order`() {
// Given
every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")
// When
val order = service.findOrder("ORD-001")
// Then
assertEquals("ORD-001", order.id)
assertEquals(299.99, order.total)
}
@Test
fun `should throw when order not found`() {
every { repo.findById(any()) } returns null
assertThrows(NoSuchElementException::class.java) {
service.findOrder("ORD-999")
}
}
}
@Nested
@DisplayName("Order Processing")
inner class OrderProcessing {
@Test
fun `should confirm pending order`() {
every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")
every { repo.save(any()) } just Runs
val processed = service.processOrder("ORD-001")
assertEquals("CONFIRMED", processed.status)
verify { repo.save(match { it.status == "CONFIRMED" }) }
}
@Test
fun `should reject non-pending order`() {
every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "SHIPPED", "CUST-001")
assertThrows(IllegalStateException::class.java) {
service.processOrder("ORD-001")
}
verify(exactly = 0) { repo.save(any()) }
}
}
@Nested
@DisplayName("Priority Calculation")
inner class PriorityCalculation {
@Test
fun `should assign HIGH priority for orders over 10000`() {
val order = Order("ORD-001", 15_000.00, "PENDING", "CUST-001")
assertEquals("HIGH", service.calculatePriority(order))
}
@Test
fun `should assign MEDIUM priority for orders between 1000 and 10000`() {
val order = Order("ORD-001", 2_500.00, "PENDING", "CUST-001")
assertEquals("MEDIUM", service.calculatePriority(order))
}
@Test
fun `should assign LOW priority for orders under 1000`() {
val order = Order("ORD-001", 299.99, "PENDING", "CUST-001")
assertEquals("LOW", service.calculatePriority(order))
}
}
@Nested
@DisplayName("Async Operations")
inner class AsyncOperations {
@Test
fun `should fetch order asynchronously`() = runTest {
coEvery { repo.fetchOrderAsync("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")
val order = service.fetchOrderAsync("ORD-001")
assertEquals("ORD-001", order.id)
coVerify { repo.fetchOrderAsync("ORD-001") }
}
}
}
Output: All tests pass ✅
❓ FAQ
Q What's the difference between MockK's relaxed mock and regular mock?
A A relaxed mock (
mockk(relaxed = true)) returns default values (0/null/false) for unstubbed methods; a regular mock throws MockKException for unstubbed methods. Prefer regular mocks to avoid hiding unconfigured behavior.Q What's the difference between runTest and runBlocking?
A
runTest uses virtual time (skips delay), so tests complete in milliseconds; runBlocking uses real time and waits for actual delay duration. Coroutine tests must use runTest.Q How do I mock a companion object?
A Use
mockkObject(Order) to mock the entire companion object, then every { ... } returns. Restore with unmockkObject(Order) after the test.Q How do I mock extension functions?
A Use
mockkStatic("package.ClassNameKt") to mock specific extension functions. However, try to avoid it — extension functions can often be refactored into regular functions for better testability.Q What percentage of test coverage is enough?
A Target 80% line coverage, but coverage doesn't equal quality. Critical business logic (payments, inventory, state machines) should have 100% coverage; simple data classes can remain untested.
Q How do I distinguish integration tests from unit tests?
A Unit tests mock all dependencies (testing a single class); integration tests use real dependencies (database, HTTP). Unit tests are fast and stable; integration tests are slower but more realistic.
📖 Summary
- JUnit 5 + MockK is the golden Kotlin testing combination
- MockK natively supports Kotlin: final classes, coroutines, extension functions, objects
every { } returnsfor stubbing,verify { }for verifying callsrunTestfor coroutine testing, using virtual time so no need to wait fordelaycoEvery/coVerifyspecifically for suspend function mocking and verification- Nested tests (
@Nested) organize related tests; parameterized tests (@ParameterizedTest) reduce duplication
📝 Exercises
- Beginner (⭐): Write JUnit 5 tests for
Orderto verifyidandstatusbehavior. Hint:@Test fun \should create order`() { ... }` - Intermediate (⭐⭐): Use MockK to mock
OrderRepositoryand testOrderService.processOrder()for both happy and error paths. Hint:every { repo.findById(any()) } returns order - Challenge (⭐⭐⭐): Use
runTest+coEveryto test an async order fetching function, verifying the timing of coroutine calls. Hint:coEvery { repo.fetchOrderAsync(any()) } returns ...



