06 · Testing Advanced¶
Level 2's testing module covered
plain JUnit 5 and Kotest fundamentals. Real services need more: mocking
dependencies you don't want to hit for real (payment gateways, external
APIs), testing suspend functions without real delays, and running the
same test logic against many inputs. This module covers MockK (a
Kotlin-native mocking library), kotlinx-coroutines-test, and
@ParameterizedTest.
dependencies {
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("io.mockk:mockk:1.13.11")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
The code under test¶
interface PaymentGateway {
suspend fun charge(cents: Int): Boolean
}
class OrderService(private val gateway: PaymentGateway) {
suspend fun placeOrder(amountCents: Int): String {
if (amountCents <= 0) throw IllegalArgumentException("amount must be positive")
return if (gateway.charge(amountCents)) "confirmed" else "declined"
}
}
fun classify(n: Int): String = when {
n < 0 -> "negative"
n == 0 -> "zero"
else -> "positive"
}
OrderService depends on a PaymentGateway interface rather than a
concrete implementation specifically so tests can substitute a fake —
this is the whole reason to prefer constructor-injected interfaces over
object-style singletons for anything that talks to the outside world.
Mocking with MockK, and testing coroutines with runTest¶
MockK's mockk<T>() creates a fake implementation; coEvery { }
stubs a suspend function's return value (every { } for non-suspend
ones). runTest (from kotlinx-coroutines-test) runs a test body as a
coroutine and — crucially — auto-advances virtual time, so a real
delay(5000) inside the code under test doesn't actually make the test
wait five seconds.
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class OrderServiceTest {
@Test
fun `charge succeeds returns confirmed`() = runTest {
val gateway = mockk<PaymentGateway>()
coEvery { gateway.charge(500) } returns true
val service = OrderService(gateway)
val result = service.placeOrder(500)
assertEquals("confirmed", result)
}
@Test
fun `charge declined returns declined`() = runTest {
val gateway = mockk<PaymentGateway>()
coEvery { gateway.charge(any()) } returns false
val service = OrderService(gateway)
assertEquals("declined", service.placeOrder(100))
}
@Test
fun `non-positive amount throws before touching the gateway`() = runTest {
val gateway = mockk<PaymentGateway>()
val service = OrderService(gateway)
assertFailsWith<IllegalArgumentException> { service.placeOrder(0) }
}
}
Note the third test never stubs gateway.charge at all — because the
IllegalArgumentException is thrown before the gateway is touched, MockK
never needs a stub for it. If placeOrder's validation were removed, this
test would fail with a MockK "no answer found" error rather than silently
passing, which is a nice guardrail: it proves the short-circuit actually
happens.
Parameterized tests¶
@ParameterizedTest with @CsvSource runs one test method against many
inputs, reporting each as a separate result.
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import kotlin.test.assertEquals
class ClassifyTest {
@ParameterizedTest(name = "classify({0}) = {1}")
@CsvSource("-5,negative", "0,zero", "7,positive")
fun `classify handles all three ranges`(input: Int, expected: String) {
assertEquals(expected, classify(input))
}
}
Running the whole suite (./gradlew test) with testLogging { events(...)
} configured to print results:
OrderServiceTest > charge succeeds returns confirmed() PASSED
OrderServiceTest > charge declined returns declined() PASSED
OrderServiceTest > non-positive amount throws before touching the gateway() PASSED
OrderServiceTest > classify handles all three ranges(int, String) > classify(-5) = negative PASSED
OrderServiceTest > classify handles all three ranges(int, String) > classify(0) = zero PASSED
OrderServiceTest > classify handles all three ranges(int, String) > classify(7) = positive PASSED
BUILD SUCCESSFUL
Kotlin-specific traps¶
every { }vs.coEvery { }. Stubbing asuspendfunction with plainevery { }fails to compile (or, with some MockK versions, compiles but never matches) —suspendfunctions always need theco- prefixed MockK variants (coEvery,coVerify).runTestisn't just "run this inrunBlocking." It uses a virtual clock, sodelay()calls inside the code under test complete instantly in test time — a test with a real 5-seconddelayfinishes in milliseconds. Don't be alarmed if your test timing assertions need to account for this.- MockK mocks are strict by default about unstubbed calls. Calling a
method you never stubbed with
every/coEverythrows at test time rather than silently returningnull— this is a feature (catches tests that don't actually test what you think), but surprises people used to more permissive mocking frameworks. assertFailsWith<T>needs the exact (or a supertype) exception type.assertFailsWith<IllegalArgumentException> { ... }won't match if the code actually throws a subtype-unrelated exception likeIllegalStateException— read the failure message carefully, it names the exception that was actually thrown.@CsvSourcevalues are always parsed as strings first, then converted to the parameter's declared type — a malformed row (wrong column count, non-numeric value for anIntparameter) fails at runtime with aCsvSource-specific error, not a compile error.
How It Actually Works¶
mockk<PaymentGateway>() doesn't write a hand-coded fake — it generates a
real class implementing PaymentGateway at runtime, using bytecode
generation (via ByteBuddy/Objenesis under MockK's hood) to synthesize a
proxy class whose every method is instrumented to record calls and consult
a table of configured stub answers instead of running real logic. This is
only possible because PaymentGateway is an interface — MockK's proxy class
implements it directly, satisfying the JVM's normal invokeinterface
dispatch, so OrderService calling gateway.charge(...) neither knows nor
cares it's calling a synthetic, freshly-generated class rather than a
production implementation. coEvery { gateway.charge(500) } returns true
works by first calling the real (proxied) method inside a special
recording mode that captures which method and arguments were invoked, then
associates that call signature with the return value in an internal map
consulted by every future matching call.
runTest solves a specific problem with testing suspend code: a real
delay(5000) suspends by scheduling a resume via a real clock, and a normal
test would have to actually wait. runTest instead runs the coroutine
under a special TestDispatcher backed by a virtual, scheduler-controlled
clock — when code under test calls delay(5000), the virtual clock simply
advances its internal counter by 5000 "milliseconds" instantly, then runs
any coroutines that became eligible to resume at that virtual time, with no
real wall-clock wait happening at all. This works because delay is itself
implemented via the current coroutine context's dispatcher/scheduler rather
than a hardcoded Thread.sleep — swapping in a different scheduler for
tests is enough to change what "time passing" means for every suspend
function running under it, without touching OrderService's code at all.
Cheat sheet¶
| Need | Tool |
|---|---|
| Fake an interface | mockk<T>() |
| Stub a non-suspend function | every { mock.fn() } returns value |
| Stub a suspend function | coEvery { mock.fn() } returns value |
| Verify a call happened | verify { mock.fn() } / coVerify { } |
| Run suspend test code | = runTest { ... } |
| Assert an exception is thrown | assertFailsWith<E> { ... } |
| Run one test over many inputs | @ParameterizedTest + @CsvSource/@ValueSource |
Exercise¶
Add a NotificationService interface with a suspend fun notify(orderId:
String): Unit method, inject it into OrderService, and call it only
when an order is "confirmed". Write a MockK test using coVerify to
assert notify was called exactly once on success, and a second test
using coVerify(exactly = 0) to assert it's never called when the charge
is declined.