Introduction
When you’re building an Android app — or even a cross-platform Kotlin Multiplatform (KMM) project — that relies on network calls, things can get slow and unreliable if you’re always calling a real server — especially during testing.
By combining MockK, Koin, and Ktor, you can “fake” your network responses, so your tests and development stay smooth and reliable — even when offline.
Note: Although we use Ktor in this article (because it’s Kotlin-friendly and perfect for KMM), the MockK + Koin approach easily applies to any networking library — such as OkHttp, Retrofit, or others. As long as you wrap your network calls in an interface (like
ApiService), you can swap in a mock or a real implementation as needed.
Enter MockK
MockK is a Kotlin-first mocking library that offers:
- Native Kotlin support: Ideal for projects using coroutines or advanced Kotlin features.
- Clear syntax:
every { ... } returns ...
to define how mocks respond, andverify
/coVerify
to confirm calls. - Flexible options: Like “relaxed” mocks to reduce boilerplate, and
coVerify
for suspend functions.
Because MockK is pure Kotlin, it often feels more natural than Java-oriented libraries — especially on modern Android and KMM projects.
Why Ktor?
Ktor is a Kotlin-native framework for building and consuming HTTP APIs. It works on the JVM, Android, iOS (via Kotlin/Native), and more. In Android apps, you might use it as your HTTP client, but in KMM projects, you can share this network logic across multiple platforms:
- Pure Kotlin: Suits KMM perfectly — no platform-specific bridging for networking.
- Lightweight & Composable: Configure pipeline features like logging, JSON parsing, and timeouts.
- Multiplatform: Same code for Android, iOS, desktop, or server-based projects.
When using MockK to fake Ktor responses, you effectively short-circuit real network requests, returning data as if Ktor had made the call — without hitting an actual server. This synergy is what makes your tests fast, reliable, and easy to maintain in both Android and KMM environments.
Mockito vs MockK
Historically, Mockito has been the go-to solution for Java-based mocking, and it can work in Kotlin with some additional steps (like enabling inline mocking or adding Kotlin extensions). However, MockK:
- Natively supports Kotlin: Suspending functions, extension methods, etc.
- Offers “relaxed” mocks to cut down on boilerplate.
- Tends to require fewer workarounds, making it particularly appealing for Kotlin Multiplatform code, where Java-specific assumptions might break.
Use Case: Faking Ktor Responses
Imagine your app (or KMM module) fetches user profiles, weather data, or anything else from a remote API via Ktor. If you rely on a real server in your tests or staging environment, you might face:
- Slower tests (network latency).
- Flaky results (server downtime or changing data).
- Difficult offline testing (losing network disrupts your tests).
Instead, you can:
- Use the real Ktor service in production.
- Switch to a mock for tests (or “stage” environment) returning predictable data, no internet required.
This is where MockK (for mocking), Koin (for easy dependency injection), and Ktor (for flexible HTTP clients) align perfectly. A simple toggle, like useMocks = true
, decides whether to rely on real or fake Ktor calls.
. . .
Implementation Steps
1. Environment & Mock Settings
First, create a small data class to hold environment info:
data class EnvironmentConfig( val environmentName: String, // e.g. "production", "stage" val useMocks: Boolean )
You can define these values in many ways — via BuildConfig
, code constants, or even your CI pipeline. Below are two approaches to wire them up in Koin:
(A) Defining Environment Info in a Koin Module
val envModule = module { single { EnvironmentConfig( environmentName = "stage", useMocks = true ) } }
Here, you simply declare the environment name ("stage"
) and set useMocks = true
so any module that depends on EnvironmentConfig
can get it from Koin.
(B) Using BuildConfig to Define Environment Info
// In your app-level build.gradle android { defaultConfig { // Example build config fields for environment + mock usage buildConfigField "String", "ENVIRONMENT", "\"stage\"" buildConfigField "boolean", "USE_MOCKS", "true" } }
Then in Kotlin code:
// You can still define a data class, or just read from BuildConfig directly: val environmentName = BuildConfig.ENVIRONMENT val useMocks = BuildConfig.USE_MOCKS
This approach is handy if you want to automatically tie environment flags to different build variants (e.g., debug
, release
, or custom “staging” variants).
2. Real API Service (Ktor)
Then you’ll need an interface plus a real implementation using Ktor:
// Important to use interface interface ApiService { suspend fun fetchData(): String } // This implementation utilizes the interface to return real data class RealApiService : ApiService { private val client = io.ktor.client.HttpClient() { // e.g. logging, timeouts, JSON config } override suspend fun fetchData(): String { // Calls a real endpoint return client.get("https://example.com/data") } }
If useMocks = false
, you’d provide RealApiService
in your code or DI setup. For KMM, this same code could be shared across Android and iOS modules—Ktor works on both.
3. Creating a Mock with MockK
Faking the Ktor calls in MockK is straightforward:
import io.mockk.every import io.mockk.mockk val mockService = mockk<ApiService>() every { mockService.fetchData() } returns "Fake response data from MockK!"
- mockk() creates a mock object.
- every { … } returns … tells it to return a certain value whenever
fetchData()
is called.
You can also use answers which gives you a lambda, thus a place to add extra code (rather than just returning a value) if you want something dynamic, like a timestamp.
every { mockService.fetchData() } answers { "Response with timestamp: ${System.currentTimeMillis()}" }
If you don’t specify how a method behaves, MockK returns defaults (null, 0, false, etc.) for that method.
4. Injecting Dependencies with Koin
Use Koin to switch between mock and real services, depending on your environment.
Below is an example that uses the Koin module approach for environment info. If you prefer BuildConfig
, just replace the lines where we retrieve EnvironmentConfig
with direct calls to BuildConfig.ENVIRONMENT
and BuildConfig.USE_MOCKS
.
val networkModule = module { single<ApiService> { val config: EnvironmentConfig = get() // from envModule if using approach (A) // If you prefer BuildConfig approach, do something like: // val environmentName = BuildConfig.ENVIRONMENT // val useMocks = BuildConfig.USE_MOCKS if (config.environmentName == "stage" && config.useMocks) { // Provide a mock val mockService = mockk<ApiService>() every { mockService.fetchData() } returns "Fake response data from MockK!" mockService } else { RealApiService() } } } // Also you can have a module to hold environment info if not in BuildConfig // In approach (A) we saw this environment module val envModule = module { single { EnvironmentConfig( environmentName = "stage", useMocks = true ) } }
- If
environmentName = "stage"
anduseMocks = true
, you inject the mockApiService
. - Otherwise, you get
RealApiService
.
Why Koin?
- Lightweight DI framework, well-suited for Kotlin (and KMM if you share modules).
- Simplifies the “if-else” logic for providing mocks vs. real objects.
- Makes it easy to override modules in tests, staging, or production builds.
Overriding Modules in Tests
If you prefer different modules for production vs. testing, you can override them in your test setup using loadKoinModules()
or the override = true
flag.
Testing with runTest
Modern Android apps often rely on coroutines. Add the coroutines test library to your build.gradle
:
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:YOUR_VERSION_HERE"
Then wrap your test logic in runTest:
@Test fun testFetchDataWithMockK() = runTest { startKoin { modules(listOf(envModule, networkModule)) } // Because environment = "stage" + useMocks = true, you get a mock val service: ApiService = get() val data = service.fetchData() assertEquals("Fake response data from MockK!", data) }
This ensures no real network calls happen and that your tests are fast and consistent.
Testing the ViewModel
Below is a quick sample of how to test a ViewModel
that depends on ApiService
:
// The ViewModel class MyViewModel(private val apiService: ApiService) : ViewModel() { val data = MutableLiveData<String>() fun loadData() { viewModelScope.launch { data.value = apiService.fetchData() } } } // Our Test class class MyViewModelTest { @Before fun setupKoin() { // Start Koin once per test class (or per test if you prefer) startKoin { modules(listOf(envModule, networkModule)) } } @After fun tearDownKoin() { // Stop Koin to avoid polluting other tests stopKoin() } @Test fun `ViewModel uses mocked service with verify`() = runTest { // The mock or real service is injected based on environment settings val viewModel = MyViewModel(get()) viewModel.loadData() // Our mock returns "Fake response data from MockK!" assertEquals("Fake response data from MockK!", viewModel.data.value) // Check that fetchData() was indeed called once using verify val service: ApiService = get() io.mockk.verify(exactly = 1) { service.fetchData() } } }
Notes:
- @Before and @After allow you to set up and tear down Koin cleanly.
- You can replicate this logic in a base test class or a JUnit test rule/extension if you have many test classes.
verify
vs. coVerify
in MockK
In MockK, both verify
and coVerify
can work with suspending functions, but:
1. coVerify
coVerify is explicitly designed for suspending (coroutine) calls.
- It ensures the verification is aligned with coroutine context handling, which can help avoid subtle concurrency or timing issues.
- It tells readers of your test, “I’m verifying a coroutine call,” improving clarity and maintainability.
2. verify
verify can pass most of the time if you’re testing suspending code inside runTest
, but in complex or asynchronous flows (e.g., parallel coroutines, multiple suspending calls, time manipulation with advanceTimeBy
), coVerify
might handle completion states more reliably.
When to use coVerify
- Whenever you’re verifying multiple or complex suspending calls: If your test triggers concurrency or sequential calls that can’t be guaranteed to finish instantly,
coVerify
reduces the risk of timing issues. - When clarity is key: Marking a verification as
coVerify
signals you’re intentionally dealing with coroutine-based calls, which helps other developers. - If you run into race conditions using
verify
in a more complex coroutine scenario, switching tocoVerify
often resolves them.
If your scenario is super simple — one call, straightforward flow, all in runTest
—verify
and coVerify
will likely behave identically. In that case, verify
is perfectly fine. But coVerify
gives you a safer, more explicit guarantee in real-world coroutine usage.
. . .
coVerify Example: Multiple Suspending Calls
Imagine a scenario where your ViewModel calls two suspending methods on the same service, possibly in parallel. With verify
, the test might pass or fail depending on timing, especially if the second call hasn’t completed when verify
runs. With coVerify
, MockK tries to ensure the coroutine calls have completed before verifying.
Service With Two Suspensions
interface ApiService { suspend fun fetchUsers(): List<String> suspend fun fetchPosts(): List<String> } class RealApiService : ApiService { override suspend fun fetchUsers(): List<String> { // Some real network call } override suspend fun fetchPosts(): List<String> { // Another real network call } }
ViewModel Triggering Both
class MyViewModel(private val apiService: ApiService) : ViewModel() { val users = MutableLiveData<List<String>>() val posts = MutableLiveData<List<String>>() fun loadAllData() { viewModelScope.launch { // In real life, you might do these in parallel or with concurrency structures users.value = apiService.fetchUsers() posts.value = apiService.fetchPosts() } } }
Test Using coVerify
class MyViewModelTest { @Test fun `test loadAllData with coVerify`() = runTest { // Suppose we inject a mockk<ApiService> via Koin or manual injection val mockService = mockk<ApiService>() every { mockService.fetchUsers() } returns listOf("Alice", "Bob") every { mockService.fetchPosts() } returns listOf("Post1", "Post2") val viewModel = MyViewModel(mockService) viewModel.loadAllData() // calls both suspending methods // Check results assertEquals(listOf("Alice", "Bob"), viewModel.users.value) assertEquals(listOf("Post1", "Post2"), viewModel.posts.value) // coVerify ensures all coroutines completed for these calls before verifying coVerify(exactly = 1) { mockService.fetchUsers() } coVerify(exactly = 1) { mockService.fetchPosts() } } }
In some advanced tests (e.g., if you used runTest { ... }
but had parallel coroutines plus time-shifting with advanceTimeBy
or advanceUntilIdle
), verify
might incorrectly run before the second call finishes. coVerify is better at ensuring it waits for suspend calls to complete (or at least that it knows they’re coroutines) before checking if they were called.
Job Offers
KMM Considerations
Because Ktor, Koin, and MockK are all pure Kotlin, you can use them in Kotlin Multiplatform Mobile (KMM) projects as well:
- Ktor client code can be shared across Android and iOS.
- MockK can test your shared (common) Kotlin code that includes business logic and network layers.
- Koin can be used to inject these dependencies in a KMM environment (though you might handle DI differently on iOS if you want a more native approach).
Regardless, the same principles apply: mock your ApiService
so you’re not hitting a real server on each test run, saving time and complexity across both platforms.
. . .
Extra Use Cases for MockK
Besides faking HTTP requests, MockK can also simplify other use cases. Here are just a few ideas:
1. Database Testing
Quickly test your repository logic without a real DB.
val mockDao = mockk<UserDao>() every { mockDao.getUsers() } returns listOf(User("Alice"))
2. Analytics & Logging
Confirm logs are called correctly — no real analytics server needed.
val mockAnalytics = mockk<AnalyticsService>() // ... io.mockk.verify { mockAnalytics.trackEvent("ScreenViewed") }
3. Push Notifications
The inverse = true
parameter (or verify(exactly = 0)
) ensures sendNotification
wasn’t called.
val mockPushManager = mockk<PushManager>() io.mockk.verify(inverse = true) { mockPushManager.sendNotification(any()) }
. . .
Wrapping Up
By mixing MockK with Ktor and Koin, you can:
- Short-circuit real network calls in your tests, boosting speed and reliability.
- Toggle between a mock or real service using either a Koin module or
BuildConfig
flags. - Test coroutines seamlessly, thanks to MockK’s Kotlin-native design.
Mockito is still a solid library, but MockK usually feels more natural for modern Kotlin — especially if you heavily use suspending functions or advanced Kotlin features. Whichever approach you pick, mocking your network layer (and other dependencies) is a surefire way to keep your tests stable, your code modular, and your development process friction-free. Enjoy your streamlined testing workflow!
This article is previously published on proandroiddev.com.