
Compiler-plugin mocking that generates compile-time stubs with an expressive DSL, argument matchers, coroutine support, final-class mocking and zero runtime reflection for type-safe testing.
A lightweight, compiler-plugin-based mocking framework for Kotlin Multiplatform. Stub generates mock implementations at compile time, enabling type-safe stubbing and verification across JVM, Android, iOS, and JS — with zero runtime reflection.
stub<T>() — create stubs for interfaces and final classes with a single callevery { mock.call() } returns value syntax inspired by MockKany(), eq() for flexible argument matchingcoEvery / coVerify for suspend functionsopen keyword required; handled at the FIR compiler phase| Platform | Target |
|---|---|
| JVM | jvm |
| Android |
androidTarget (minSdk 24) |
| iOS |
iosArm64, iosSimulatorArm64, iosX64
|
| JavaScript |
js (Node.js) |
Stub is distributed as a set of Gradle modules. Add the compiler plugin and DSL dependency to your project:
settings.gradle.kts
includeBuild("build-logic")
include(":stub:compiler-plugin")
include(":stub:dsl")
include(":stub:runtime")your-module/build.gradle.kts
plugins {
alias(libs.plugins.stubCompiler) // applies the compiler plugin
alias(libs.plugins.stubKotlinMultiplatform)
}
kotlin {
sourceSets {
commonTest.dependencies {
implementation(project(":stub:dsl")) // includes :stub:runtime transitively
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test) // only if using coEvery/coVerify
}
}
}The
stub:dslmodule declaresapi(project(":stub:runtime")), so you only need to depend on:stub:dsl.
import org.yarokovisty.stub.dsl.*
import kotlin.test.Test
import kotlin.test.assertEquals
interface UserRepository {
fun findById(id: Int): String
}
class UserRepositoryTest {
private val repository: UserRepository = stub()
@Test
fun returnsConfiguredValue() {
every { repository.findById(1) } returns "Alice"
assertEquals("Alice", repository.findById(1))
verify { repository.findById(1) }
}
}That's it — no annotations, no manual mock classes, no open modifiers.
Use stub<T>() to create a stub instance of any interface or class:
// Stub an interface
val repository: UserRepository = stub()
// Stub a final class — no `open` keyword needed
val dataSource: DataSource = stub()The compiler plugin intercepts stub<T>() calls and generates a synthetic class that:
StubDelegate
every { repository.findById(1) } returns "Alice"every { repository.findById(-1) } throws IllegalArgumentException("Invalid ID")every { repository.findById(any()) } answers { call ->
val id = call.args[0] as Int
"User #$id"
}The answers lambda receives a MethodCall object with methodName and args.
Assert that a method was called with the expected arguments:
every { repository.findById(1) } returns "Alice"
repository.findById(1)
verify { repository.findById(1) }If the method was not called, verify throws an IllegalStateException.
// Clear all configured answers AND recorded call history
clearStubs(repository)
// Clear only the recorded call history (keep answers intact)
clearInvocations(repository)Stub provides two argument matchers for flexible stubbing and verification:
| Matcher | Description |
|---|---|
any<T>() |
Matches any argument value |
eq(value) |
Matches only values equal to value
|
every { dataSource.getData(any(), any()) } returns ExampleData(0, "fallback")
dataSource.getData(1, "a") // returns ExampleData(0, "fallback")
dataSource.getData(2, "b") // returns ExampleData(0, "fallback")every { dataSource.getData(eq(5), eq("test")) } returns ExampleData(5, "test")
dataSource.getData(5, "test") // returns ExampleData(5, "test")
dataSource.getData(5, "other") // throws MissingAnswerExceptionWhen no matchers are used, all arguments are matched by exact equality:
// These are equivalent:
every { dataSource.getData(1, "name") } returns result
every { dataSource.getData(eq(1), eq("name")) } returns resultThe last registered answer wins. This allows you to set up a fallback and then override specific cases:
every { dataSource.getData(any(), any()) } returns ExampleData(0, "fallback")
every { dataSource.getData(1, "special") } returns ExampleData(1, "special")
dataSource.getData(1, "special") // ExampleData(1, "special") — specific match
dataSource.getData(2, "other") // ExampleData(0, "fallback") — wildcard fallbackInterfaces are stubbed directly — no special configuration needed:
interface ExampleRepository {
fun getString(): String
fun getData(id: Int, name: String): ExampleData
suspend fun getData(): ExampleData
}
val repository: ExampleRepository = stub()
every { repository.getString() } returns "mocked"Final classes are stubbed the same way. The compiler plugin opens classes, functions, and properties at the FIR phase, so no open keyword is required in your production code:
class ExampleDataSource(private val httpClient: HttpClient) {
fun getString(): String = httpClient.getString()
fun getData(id: Int, name: String): ExampleData = httpClient.getData(id, name)
}
val dataSource: ExampleDataSource = stub()
every { dataSource.getString() } returns "mocked"The plugin automatically provides default values for non-nullable constructor parameters when generating the stub class.
Use coEvery and coVerify for suspend functions:
import kotlinx.coroutines.test.runTest
@Test
fun testSuspendFunction() = runTest {
val repository: ExampleRepository = stub()
coEvery { repository.getData() } returns ExampleData(1, "async")
val result = repository.getData()
assertEquals(ExampleData(1, "async"), result)
coVerify { repository.getData() }
}coEvery and coVerify must be called from a suspend context (e.g., inside runTest).
If a stubbed method is called without a configured answer, a MissingAnswerException is thrown:
val repository: ExampleRepository = stub()
// Throws: No answer configured for method 'getString'.
// Use every { } returns ... to configure.
repository.getString()This fail-fast behavior ensures your tests are explicit about expected interactions.
If verify is called for a method that was not invoked, it throws IllegalStateException:
every { repository.getString() } returns "data"
// Throws: Method 'getString' was never called
verify { repository.getString() }Stub consists of three modules:
stub:runtime Core engine (StubDelegate, MockRecorder, Matcher, Answer)
stub:dsl User-facing API (every, verify, coEvery, coVerify, stub<T>())
stub:compiler-plugin Kotlin compiler plugin (FIR + IR extensions)
StubDelegate — thread-safe call handler that stores configured answers and records method invocations. Uses AtomicReference for lock-free operation across all platforms.MockRecorder — singleton that captures method calls during every { } and verify { } recording blocks.MatcherStack — thread-safe stack that collects argument matchers pushed by any() and eq() calls.Answer — sealed interface with three variants: Value, Throwing, and Lambda.The compiler plugin operates in two phases:
Opens all non-sealed classes, functions, and properties by changing their modality from FINAL to OPEN. This happens at the FIR (Frontend IR) level, before IR lowering, which is the only reliable way to open final classes in the K2 compiler.
stub<T>() (specifically the createStub function in org.yarokovisty.stub.dsl)T from the call siteStub__T class that:
Stubbable with a StubDelegate fieldstubDelegate.handle()
createStub(...) call with a direct constructor call to Stub__T()
| Approach | JVM | Native | JS |
|---|---|---|---|
| Runtime reflection | Works | Not available | Not available |
| Dynamic proxy | Works | Not available | Not available |
| Compile-time generation | Works | Works | Works |
By generating stubs at compile time, Stub works identically across all Kotlin/Multiplatform targets without platform-specific code.
Configure only the methods your test actually exercises. The fail-fast MissingAnswerException ensures unused methods are not silently ignored:
// Good — explicit about what this test needs
every { repository.findById(1) } returns "Alice"
// Avoid — configuring methods unrelated to the test
every { repository.findById(any()) } returns "Alice"
every { repository.findAll() } returns emptyList()
every { repository.count() } returns 1When the test doesn't care about specific argument values, use any() for clarity:
every { logger.log(any(), any()) } returns UnitSet up a wildcard fallback first, then override specific cases:
every { repository.findById(any()) } returns "Unknown"
every { repository.findById(eq(1)) } returns "Alice"
every { repository.findById(eq(2)) } returns "Bob"If you reuse stub instances across tests, call clearStubs() or clearInvocations() in a setup method to avoid state leakage:
@BeforeTest
fun setUp() {
clearStubs(repository)
}While Stub supports final class mocking, designing your code with interfaces at module boundaries leads to cleaner tests and better separation of concerns.
Error: Stub compiler plugin is not applied.
Cause: The stub<T>() call reached the default createStub() implementation, which means the compiler plugin did not transform it.
Fix: Ensure the compiler plugin is applied in your build.gradle.kts:
plugins {
alias(libs.plugins.stubCompiler)
}Error: No answer configured for method 'methodName'. Use every { } returns ... to configure.
Cause: A stubbed method was called without configuring a return value.
Fix: Add an every { ... } returns ... block for the method before calling it.
Error: IllegalStateException: Method 'methodName' was never called
Cause: The verify { } block asserts a method was called, but it wasn't.
Fix: Ensure the code under test actually invokes the stubbed method, and that argument values match the verification expectation.
If you use any() for one argument, make sure all arguments in the same call also use matchers:
// Correct — all arguments use matchers
every { service.process(any(), eq("value")) } returns result
// Correct — no matchers (all matched by exact equality)
every { service.process(1, "value") } returns result| Platform | Notes |
|---|---|
| JVM / Android | Full support. No additional configuration required. |
| iOS (Native) | Full support. No reflection used — all stubs are generated at compile time. |
| JS (Node.js) | Full support. Runs on Node.js runtime via Kotlin/JS. |
| Dependency | Version |
|---|---|
| Kotlin | 2.2.20 |
| Gradle | 8.14+ |
| kotlinx-coroutines | 1.10.2 (for coEvery/coVerify) |
| Android Gradle Plugin | 8.12.0 (for Android targets) |
| Android compileSdk | 36 |
| Android minSdk | 24 |
# JVM tests only
./gradlew :samples:jvmTest
# All platform tests
./gradlew :samples:allTests
# Static analysis
./gradlew detektCopyright (c) YarokovistY. All rights reserved.
A lightweight, compiler-plugin-based mocking framework for Kotlin Multiplatform. Stub generates mock implementations at compile time, enabling type-safe stubbing and verification across JVM, Android, iOS, and JS — with zero runtime reflection.
stub<T>() — create stubs for interfaces and final classes with a single callevery { mock.call() } returns value syntax inspired by MockKany(), eq() for flexible argument matchingcoEvery / coVerify for suspend functionsopen keyword required; handled at the FIR compiler phase| Platform | Target |
|---|---|
| JVM | jvm |
| Android |
androidTarget (minSdk 24) |
| iOS |
iosArm64, iosSimulatorArm64, iosX64
|
| JavaScript |
js (Node.js) |
Stub is distributed as a set of Gradle modules. Add the compiler plugin and DSL dependency to your project:
settings.gradle.kts
includeBuild("build-logic")
include(":stub:compiler-plugin")
include(":stub:dsl")
include(":stub:runtime")your-module/build.gradle.kts
plugins {
alias(libs.plugins.stubCompiler) // applies the compiler plugin
alias(libs.plugins.stubKotlinMultiplatform)
}
kotlin {
sourceSets {
commonTest.dependencies {
implementation(project(":stub:dsl")) // includes :stub:runtime transitively
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test) // only if using coEvery/coVerify
}
}
}The
stub:dslmodule declaresapi(project(":stub:runtime")), so you only need to depend on:stub:dsl.
import org.yarokovisty.stub.dsl.*
import kotlin.test.Test
import kotlin.test.assertEquals
interface UserRepository {
fun findById(id: Int): String
}
class UserRepositoryTest {
private val repository: UserRepository = stub()
@Test
fun returnsConfiguredValue() {
every { repository.findById(1) } returns "Alice"
assertEquals("Alice", repository.findById(1))
verify { repository.findById(1) }
}
}That's it — no annotations, no manual mock classes, no open modifiers.
Use stub<T>() to create a stub instance of any interface or class:
// Stub an interface
val repository: UserRepository = stub()
// Stub a final class — no `open` keyword needed
val dataSource: DataSource = stub()The compiler plugin intercepts stub<T>() calls and generates a synthetic class that:
StubDelegate
every { repository.findById(1) } returns "Alice"every { repository.findById(-1) } throws IllegalArgumentException("Invalid ID")every { repository.findById(any()) } answers { call ->
val id = call.args[0] as Int
"User #$id"
}The answers lambda receives a MethodCall object with methodName and args.
Assert that a method was called with the expected arguments:
every { repository.findById(1) } returns "Alice"
repository.findById(1)
verify { repository.findById(1) }If the method was not called, verify throws an IllegalStateException.
// Clear all configured answers AND recorded call history
clearStubs(repository)
// Clear only the recorded call history (keep answers intact)
clearInvocations(repository)Stub provides two argument matchers for flexible stubbing and verification:
| Matcher | Description |
|---|---|
any<T>() |
Matches any argument value |
eq(value) |
Matches only values equal to value
|
every { dataSource.getData(any(), any()) } returns ExampleData(0, "fallback")
dataSource.getData(1, "a") // returns ExampleData(0, "fallback")
dataSource.getData(2, "b") // returns ExampleData(0, "fallback")every { dataSource.getData(eq(5), eq("test")) } returns ExampleData(5, "test")
dataSource.getData(5, "test") // returns ExampleData(5, "test")
dataSource.getData(5, "other") // throws MissingAnswerExceptionWhen no matchers are used, all arguments are matched by exact equality:
// These are equivalent:
every { dataSource.getData(1, "name") } returns result
every { dataSource.getData(eq(1), eq("name")) } returns resultThe last registered answer wins. This allows you to set up a fallback and then override specific cases:
every { dataSource.getData(any(), any()) } returns ExampleData(0, "fallback")
every { dataSource.getData(1, "special") } returns ExampleData(1, "special")
dataSource.getData(1, "special") // ExampleData(1, "special") — specific match
dataSource.getData(2, "other") // ExampleData(0, "fallback") — wildcard fallbackInterfaces are stubbed directly — no special configuration needed:
interface ExampleRepository {
fun getString(): String
fun getData(id: Int, name: String): ExampleData
suspend fun getData(): ExampleData
}
val repository: ExampleRepository = stub()
every { repository.getString() } returns "mocked"Final classes are stubbed the same way. The compiler plugin opens classes, functions, and properties at the FIR phase, so no open keyword is required in your production code:
class ExampleDataSource(private val httpClient: HttpClient) {
fun getString(): String = httpClient.getString()
fun getData(id: Int, name: String): ExampleData = httpClient.getData(id, name)
}
val dataSource: ExampleDataSource = stub()
every { dataSource.getString() } returns "mocked"The plugin automatically provides default values for non-nullable constructor parameters when generating the stub class.
Use coEvery and coVerify for suspend functions:
import kotlinx.coroutines.test.runTest
@Test
fun testSuspendFunction() = runTest {
val repository: ExampleRepository = stub()
coEvery { repository.getData() } returns ExampleData(1, "async")
val result = repository.getData()
assertEquals(ExampleData(1, "async"), result)
coVerify { repository.getData() }
}coEvery and coVerify must be called from a suspend context (e.g., inside runTest).
If a stubbed method is called without a configured answer, a MissingAnswerException is thrown:
val repository: ExampleRepository = stub()
// Throws: No answer configured for method 'getString'.
// Use every { } returns ... to configure.
repository.getString()This fail-fast behavior ensures your tests are explicit about expected interactions.
If verify is called for a method that was not invoked, it throws IllegalStateException:
every { repository.getString() } returns "data"
// Throws: Method 'getString' was never called
verify { repository.getString() }Stub consists of three modules:
stub:runtime Core engine (StubDelegate, MockRecorder, Matcher, Answer)
stub:dsl User-facing API (every, verify, coEvery, coVerify, stub<T>())
stub:compiler-plugin Kotlin compiler plugin (FIR + IR extensions)
StubDelegate — thread-safe call handler that stores configured answers and records method invocations. Uses AtomicReference for lock-free operation across all platforms.MockRecorder — singleton that captures method calls during every { } and verify { } recording blocks.MatcherStack — thread-safe stack that collects argument matchers pushed by any() and eq() calls.Answer — sealed interface with three variants: Value, Throwing, and Lambda.The compiler plugin operates in two phases:
Opens all non-sealed classes, functions, and properties by changing their modality from FINAL to OPEN. This happens at the FIR (Frontend IR) level, before IR lowering, which is the only reliable way to open final classes in the K2 compiler.
stub<T>() (specifically the createStub function in org.yarokovisty.stub.dsl)T from the call siteStub__T class that:
Stubbable with a StubDelegate fieldstubDelegate.handle()
createStub(...) call with a direct constructor call to Stub__T()
| Approach | JVM | Native | JS |
|---|---|---|---|
| Runtime reflection | Works | Not available | Not available |
| Dynamic proxy | Works | Not available | Not available |
| Compile-time generation | Works | Works | Works |
By generating stubs at compile time, Stub works identically across all Kotlin/Multiplatform targets without platform-specific code.
Configure only the methods your test actually exercises. The fail-fast MissingAnswerException ensures unused methods are not silently ignored:
// Good — explicit about what this test needs
every { repository.findById(1) } returns "Alice"
// Avoid — configuring methods unrelated to the test
every { repository.findById(any()) } returns "Alice"
every { repository.findAll() } returns emptyList()
every { repository.count() } returns 1When the test doesn't care about specific argument values, use any() for clarity:
every { logger.log(any(), any()) } returns UnitSet up a wildcard fallback first, then override specific cases:
every { repository.findById(any()) } returns "Unknown"
every { repository.findById(eq(1)) } returns "Alice"
every { repository.findById(eq(2)) } returns "Bob"If you reuse stub instances across tests, call clearStubs() or clearInvocations() in a setup method to avoid state leakage:
@BeforeTest
fun setUp() {
clearStubs(repository)
}While Stub supports final class mocking, designing your code with interfaces at module boundaries leads to cleaner tests and better separation of concerns.
Error: Stub compiler plugin is not applied.
Cause: The stub<T>() call reached the default createStub() implementation, which means the compiler plugin did not transform it.
Fix: Ensure the compiler plugin is applied in your build.gradle.kts:
plugins {
alias(libs.plugins.stubCompiler)
}Error: No answer configured for method 'methodName'. Use every { } returns ... to configure.
Cause: A stubbed method was called without configuring a return value.
Fix: Add an every { ... } returns ... block for the method before calling it.
Error: IllegalStateException: Method 'methodName' was never called
Cause: The verify { } block asserts a method was called, but it wasn't.
Fix: Ensure the code under test actually invokes the stubbed method, and that argument values match the verification expectation.
If you use any() for one argument, make sure all arguments in the same call also use matchers:
// Correct — all arguments use matchers
every { service.process(any(), eq("value")) } returns result
// Correct — no matchers (all matched by exact equality)
every { service.process(1, "value") } returns result| Platform | Notes |
|---|---|
| JVM / Android | Full support. No additional configuration required. |
| iOS (Native) | Full support. No reflection used — all stubs are generated at compile time. |
| JS (Node.js) | Full support. Runs on Node.js runtime via Kotlin/JS. |
| Dependency | Version |
|---|---|
| Kotlin | 2.2.20 |
| Gradle | 8.14+ |
| kotlinx-coroutines | 1.10.2 (for coEvery/coVerify) |
| Android Gradle Plugin | 8.12.0 (for Android targets) |
| Android compileSdk | 36 |
| Android minSdk | 24 |
# JVM tests only
./gradlew :samples:jvmTest
# All platform tests
./gradlew :samples:allTests
# Static analysis
./gradlew detektCopyright (c) YarokovistY. All rights reserved.