Back to Blog

Kotlin Multiplatform Testing in 2025: Complete Guide to Unit, Integration & UI Tests

Master KMP testing with this complete guide. Learn to write shared tests once, run everywhere. Includes Kotest, Turbine, and MockK examples with real production patterns.

Posted by

Kotlin Multiplatform Testing in 2025: Complete Guide to Unit, Integration & UI Tests

TL;DR: Why testing in Kotlin Multiplatform changes everything

Write your tests once. Run them on Android, iOS, and JVM automatically. That's the promise of Kotlin Multiplatform testing, and it actually delivers.
The reality: Companies like Cash App and McDonald's aren't just sharing business logic. They're sharing their entire test suites. One set of tests validates payment processing on both platforms. One set of tests ensures data synchronization works correctly everywhere.
The benefit: When you fix a bug in shared code, you write one test. That test runs on every platform automatically. No duplicate test code. No platform-specific testing inconsistencies. No "it works on Android but fails on iOS" surprises.
This guide shows you exactly how to test KMP code in production, with real examples from apps processing millions of transactions.

Why testing matters even more in multiplatform code

Here's a hard truth: bugs in shared code affect multiple platforms simultaneously. A payment validation bug doesn't just break Android, it breaks iOS too. The stakes are higher.
But here's the opportunity: tests in shared code protect multiple platforms simultaneously. Write one comprehensive test suite, and you've secured both Android and iOS. The return on investment is massive.

The testing paradox in cross-platform development

Traditional cross-platform frameworks have a dirty secret: they promise "write once, run anywhere" for features, but testing often becomes "write twice, debug everywhere."
React Native? You're writing Jest tests for business logic, Detox for UI, platform-specific tests for native modules.
Flutter? Dart tests for logic, widget tests for UI, platform channel mocks for native code.
Kotlin Multiplatform flips this: your tests are as multiplatform as your code.

The three layers of KMP testing

Just like your architecture has layers (UI, domain, data), your tests should follow the same structure. Here's how successful KMP teams organize their testing strategy:

Layer 1: Unit tests for shared business logic

What to test: Pure functions, data transformations, validation rules, calculations
Where to write: commonTest source set
Tools: kotlin.test, Kotest, Turbine for flows
Coverage target: 80%+ (this is your safety net)
Why it matters: These tests run on ALL platforms. Write once, validate everywhere.

Layer 2: Integration tests for platform interaction

What to test: Database operations, network requests, platform API usage, expect/actual implementations
Where to write: commonTest for shared behavior, androidTest/iosTest for platform specifics
Tools: SQLDelight test helpers, Ktor Mock Engine, MockK/Mockative
Coverage target: 60-70% (focus on critical paths)
Why it matters: Ensures your shared code works correctly with platform-specific implementations.

Layer 3: UI tests (native or shared)

What to test: User workflows, screen transitions, UI state management
Where to write: Platform-specific for native UI, commonTest for Compose Multiplatform
Tools: Espresso (Android), XCUITest (iOS), Compose UI Testing (shared)
Coverage target: 30-40% (critical user journeys only)
Why it matters: Validates the complete user experience from button tap to result.

Setting up your KMP testing environment

Before we write tests, let's configure your project correctly. This matters more than you think: poor setup leads to flaky tests and frustrated developers.

Build configuration for testing

Your build.gradle.kts in the shared module needs proper test dependencies:
kotlin
kotlin { // Your existing platform targets androidTarget() iosArm64() iosSimulatorArm64() sourceSets { // Common tests - run on ALL platforms commonTest.dependencies { implementation(kotlin("test")) // Kotest for expressive assertions implementation("io.kotest:kotest-assertions-core:5.8.0") // Turbine for testing Flows implementation("app.cash.turbine:turbine:1.0.0") // Coroutine testing implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") // MockK multiplatform support implementation("io.mockative:mockative:2.0.1") } // Android-specific test dependencies androidUnitTest.dependencies { implementation("junit:junit:4.13.2") implementation("io.mockk:mockk:1.13.9") implementation("androidx.test:core:1.5.0") } // iOS-specific test dependencies (if needed) iosTest.dependencies { // Usually empty - commonTest covers most cases } } }

Project structure for testability

Organize your test files to mirror your source structure:
shared/
├── src/
│   ├── commonMain/
│   │   └── kotlin/
│   │       ├── domain/
│   │       │   ├── PaymentProcessor.kt
│   │       │   └── OrderValidator.kt
│   │       └── data/
│   │           ├── repository/
│   │           └── network/
│   │
│   ├── commonTest/          ← Most tests go here
│   │   └── kotlin/
│   │       ├── domain/
│   │       │   ├── PaymentProcessorTest.kt
│   │       │   └── OrderValidatorTest.kt
│   │       └── data/
│   │           ├── repository/
│   │           └── network/
│   │
│   ├── androidUnitTest/     ← Android-specific tests only
│   │   └── kotlin/
│   │
│   └── iosTest/             ← iOS-specific tests only
│       └── kotlin/
Golden rule: Put tests in commonTest by default. Only use platform-specific test directories when absolutely necessary.

Unit testing shared business logic: Real examples

Let's test actual production patterns. These examples mirror what companies like Cash App and McDonald's test in their shared code.

Example 1: Testing a payment validator (like McDonald's uses)

Here's a simplified payment validator that needs thorough testing:
kotlin
// shared/src/commonMain/kotlin/domain/PaymentValidator.kt class PaymentValidator { sealed class ValidationResult { data object Valid : ValidationResult() data class Invalid(val errors: List<ValidationError>) : ValidationResult() } enum class ValidationError { AMOUNT_TOO_LOW, AMOUNT_TOO_HIGH, INVALID_CURRENCY, CARD_EXPIRED, INSUFFICIENT_FUNDS } fun validate( amount: Double, currency: String, cardExpiryMonth: Int, cardExpiryYear: Int, availableBalance: Double ): ValidationResult { val errors = mutableListOf<ValidationError>() // Amount validation if (amount <= 0.0) errors.add(ValidationError.AMOUNT_TOO_LOW) if (amount > 10000.0) errors.add(ValidationError.AMOUNT_TOO_HIGH) // Currency validation if (currency !in listOf("USD", "EUR", "GBP")) { errors.add(ValidationError.INVALID_CURRENCY) } // Expiry validation val currentYear = getCurrentYear() val currentMonth = getCurrentMonth() if (cardExpiryYear < currentYear || (cardExpiryYear == currentYear && cardExpiryMonth < currentMonth)) { errors.add(ValidationError.CARD_EXPIRED) } // Balance validation if (amount > availableBalance) { errors.add(ValidationError.INSUFFICIENT_FUNDS) } return if (errors.isEmpty()) { ValidationResult.Valid } else { ValidationResult.Invalid(errors) } } // Platform-agnostic date helpers private fun getCurrentYear(): Int = 2025 // In real code, use expect/actual private fun getCurrentMonth(): Int = 1 }
Now let's write comprehensive tests using Kotest's expressive assertions:
kotlin
// shared/src/commonTest/kotlin/domain/PaymentValidatorTest.kt import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlin.test.Test class PaymentValidatorTest { private val validator = PaymentValidator() @Test fun `valid payment passes all checks`() { val result = validator.validate( amount = 50.0, currency = "USD", cardExpiryMonth = 12, cardExpiryYear = 2026, availableBalance = 100.0 ) result.shouldBeInstanceOf<PaymentValidator.ValidationResult.Valid>() } @Test fun `zero amount is rejected`() { val result = validator.validate( amount = 0.0, currency = "USD", cardExpiryMonth = 12, cardExpiryYear = 2026, availableBalance = 100.0 ) result.shouldBeInstanceOf<PaymentValidator.ValidationResult.Invalid>() val invalid = result as PaymentValidator.ValidationResult.Invalid invalid.errors shouldBe listOf(PaymentValidator.ValidationError.AMOUNT_TOO_LOW) } @Test fun `amount exceeding limit is rejected`() { val result = validator.validate( amount = 15000.0, currency = "USD", cardExpiryMonth = 12, cardExpiryYear = 2026, availableBalance = 20000.0 ) result.shouldBeInstanceOf<PaymentValidator.ValidationResult.Invalid>() val invalid = result as PaymentValidator.ValidationResult.Invalid invalid.errors shouldBe listOf(PaymentValidator.ValidationError.AMOUNT_TOO_HIGH) } @Test fun `unsupported currency is rejected`() { val result = validator.validate( amount = 50.0, currency = "JPY", cardExpiryMonth = 12, cardExpiryYear = 2026, availableBalance = 100.0 ) result.shouldBeInstanceOf<PaymentValidator.ValidationResult.Invalid>() val invalid = result as PaymentValidator.ValidationResult.Invalid invalid.errors shouldBe listOf(PaymentValidator.ValidationError.INVALID_CURRENCY) } @Test fun `expired card is rejected`() { val result = validator.validate( amount = 50.0, currency = "USD", cardExpiryMonth = 12, cardExpiryYear = 2024, availableBalance = 100.0 ) result.shouldBeInstanceOf<PaymentValidator.ValidationResult.Invalid>() val invalid = result as PaymentValidator.ValidationResult.Invalid invalid.errors shouldBe listOf(PaymentValidator.ValidationError.CARD_EXPIRED) } @Test fun `insufficient balance is rejected`() { val result = validator.validate( amount = 150.0, currency = "USD", cardExpiryMonth = 12, cardExpiryYear = 2026, availableBalance = 100.0 ) result.shouldBeInstanceOf<PaymentValidator.ValidationResult.Invalid>() val invalid = result as PaymentValidator.ValidationResult.Invalid invalid.errors shouldBe listOf(PaymentValidator.ValidationError.INSUFFICIENT_FUNDS) } @Test fun `multiple validation errors are accumulated`() { val result = validator.validate( amount = 15000.0, currency = "JPY", cardExpiryMonth = 12, cardExpiryYear = 2024, availableBalance = 100.0 ) result.shouldBeInstanceOf<PaymentValidator.ValidationResult.Invalid>() val invalid = result as PaymentValidator.ValidationResult.Invalid // Should catch all four errors invalid.errors.size shouldBe 4 invalid.errors.toSet() shouldBe setOf( PaymentValidator.ValidationError.AMOUNT_TOO_HIGH, PaymentValidator.ValidationError.INVALID_CURRENCY, PaymentValidator.ValidationError.CARD_EXPIRED, PaymentValidator.ValidationError.INSUFFICIENT_FUNDS ) } }
Key insight: These tests run on Android AND iOS automatically. You write them once, and both platforms are covered. This is where KMP testing shines.

Example 2: Testing async code with Kotlin Flows (like Netflix uses)

Modern KMP apps use Flows for reactive data. Here's how to test them properly with Turbine:
kotlin
// shared/src/commonMain/kotlin/data/OrderRepository.kt import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.delay class OrderRepository( private val api: OrderApi, private val database: OrderDatabase ) { fun observeOrderStatus(orderId: String): Flow<OrderStatus> = flow { // Emit initial status from database val cachedStatus = database.getOrderStatus(orderId) if (cachedStatus != null) { emit(cachedStatus) } // Poll API every 5 seconds for updates while (true) { try { val freshStatus = api.fetchOrderStatus(orderId) database.saveOrderStatus(orderId, freshStatus) emit(freshStatus) delay(5000) } catch (e: Exception) { emit(OrderStatus.Error(e.message ?: "Unknown error")) break } } } } sealed class OrderStatus { data object Pending : OrderStatus() data object Processing : OrderStatus() data object Completed : OrderStatus() data class Error(val message: String) : OrderStatus() }
Testing this Flow-based code with Turbine makes it clean and readable:
kotlin
// shared/src/commonTest/kotlin/data/OrderRepositoryTest.kt import app.cash.turbine.test import io.kotest.matchers.shouldBe import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.time.Duration.Companion.seconds class OrderRepositoryTest { @Test fun `observeOrderStatus emits cached value first`() = runTest { // Given val mockApi = FakeOrderApi() val mockDatabase = FakeOrderDatabase().apply { saveOrderStatus("order-123", OrderStatus.Processing) } val repository = OrderRepository(mockApi, mockDatabase) // When/Then repository.observeOrderStatus("order-123").test { // First emission should be cached value awaitItem() shouldBe OrderStatus.Processing // Cancel to avoid waiting for next emission cancelAndIgnoreRemainingEvents() } } @Test fun `observeOrderStatus polls API and emits updates`() = runTest { // Given val mockApi = FakeOrderApi().apply { scheduleResponses( "order-123", OrderStatus.Pending, OrderStatus.Processing, OrderStatus.Completed ) } val mockDatabase = FakeOrderDatabase() val repository = OrderRepository(mockApi, mockDatabase) // When/Then repository.observeOrderStatus("order-123").test(timeout = 20.seconds) { awaitItem() shouldBe OrderStatus.Pending awaitItem() shouldBe OrderStatus.Processing awaitItem() shouldBe OrderStatus.Completed cancelAndIgnoreRemainingEvents() } } @Test fun `observeOrderStatus emits error on API failure`() = runTest { // Given val mockApi = FakeOrderApi().apply { scheduleError("order-123", "Network timeout") } val mockDatabase = FakeOrderDatabase() val repository = OrderRepository(mockApi, mockDatabase) // When/Then repository.observeOrderStatus("order-123").test { val errorStatus = awaitItem() errorStatus.shouldBeInstanceOf<OrderStatus.Error>() (errorStatus as OrderStatus.Error).message shouldBe "Network timeout" awaitComplete() } } @Test fun `observeOrderStatus saves to database on each API response`() = runTest { // Given val mockApi = FakeOrderApi().apply { scheduleResponses("order-123", OrderStatus.Completed) } val mockDatabase = FakeOrderDatabase() val repository = OrderRepository(mockApi, mockDatabase) // When repository.observeOrderStatus("order-123").test { awaitItem() // Consume the emission cancelAndIgnoreRemainingEvents() } // Then mockDatabase.getOrderStatus("order-123") shouldBe OrderStatus.Completed } } // Test fakes (shared test code!) class FakeOrderApi : OrderApi { private val scheduledResponses = mutableMapOf<String, MutableList<OrderStatus>>() private val scheduledErrors = mutableMapOf<String, String>() fun scheduleResponses(orderId: String, vararg statuses: OrderStatus) { scheduledResponses[orderId] = statuses.toMutableList() } fun scheduleError(orderId: String, message: String) { scheduledErrors[orderId] = message } override suspend fun fetchOrderStatus(orderId: String): OrderStatus { scheduledErrors[orderId]?.let { throw Exception(it) } return scheduledResponses[orderId]?.removeFirstOrNull() ?: OrderStatus.Pending } } class FakeOrderDatabase : OrderDatabase { private val storage = mutableMapOf<String, OrderStatus>() override suspend fun getOrderStatus(orderId: String): OrderStatus? { return storage[orderId] } override suspend fun saveOrderStatus(orderId: String, status: OrderStatus) { storage[orderId] = status } }
Why Turbine matters: Testing Flows without Turbine is painful. You have to manually collect, handle timing, and manage cancellation. Turbine makes Flow testing as simple as regular function testing.

Integration testing: Database, network, and platform APIs

Integration tests validate that your shared code works correctly with platform-specific implementations. This is where expect/actual comes in.

Testing SQLDelight databases

If you're using SQLDelight (like Cash App does), you need to test your database queries. Here's how:
kotlin
// shared/src/commonTest/kotlin/data/UserDatabaseTest.kt import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlinx.coroutines.test.runTest class UserDatabaseTest { private fun createTestDatabase(): AppDatabase { // In real code, use in-memory database driver // Android: use AndroidSqliteDriver with in-memory DB // iOS: use NativeSqliteDriver with in-memory DB return createInMemoryDatabase() } @Test fun `insert and retrieve user`() = runTest { val database = createTestDatabase() val userDao = database.userQueries // Insert user userDao.insertUser( id = "user-123", name = "John Doe", email = "john@example.com" ) // Retrieve user val user = userDao.getUserById("user-123").executeAsOne() assertEquals("John Doe", user.name) assertEquals("john@example.com", user.email) } @Test fun `update user information`() = runTest { val database = createTestDatabase() val userDao = database.userQueries // Insert initial user userDao.insertUser( id = "user-123", name = "John Doe", email = "john@example.com" ) // Update user userDao.updateUserName( id = "user-123", name = "Jane Doe" ) // Verify update val user = userDao.getUserById("user-123").executeAsOne() assertEquals("Jane Doe", user.name) } @Test fun `delete user removes from database`() = runTest { val database = createTestDatabase() val userDao = database.userQueries // Insert user userDao.insertUser( id = "user-123", name = "John Doe", email = "john@example.com" ) // Delete user userDao.deleteUser("user-123") // Verify deletion val user = userDao.getUserById("user-123").executeAsOneOrNull() assertNull(user) } @Test fun `query users by email domain`() = runTest { val database = createTestDatabase() val userDao = database.userQueries // Insert multiple users userDao.insertUser("user-1", "Alice", "alice@company.com") userDao.insertUser("user-2", "Bob", "bob@company.com") userDao.insertUser("user-3", "Charlie", "charlie@gmail.com") // Query by domain val companyUsers = userDao.getUsersByEmailDomain("%@company.com") .executeAsList() assertEquals(2, companyUsers.size) assertEquals(setOf("Alice", "Bob"), companyUsers.map { it.name }.toSet()) } } // Platform-specific database creation expect fun createInMemoryDatabase(): AppDatabase
Then implement the expect function for each platform:
kotlin
// shared/src/androidUnitTest/kotlin/data/DatabaseDriver.kt import com.squareup.sqldelight.android.AndroidSqliteDriver import com.squareup.sqldelight.db.SqlDriver actual fun createInMemoryDatabase(): AppDatabase { val driver: SqlDriver = AndroidSqliteDriver( schema = AppDatabase.Schema, context = getTestContext(), name = null // null = in-memory ) return AppDatabase(driver) }
kotlin
// shared/src/iosTest/kotlin/data/DatabaseDriver.kt import com.squareup.sqldelight.drivers.native.NativeSqliteDriver import com.squareup.sqldelight.db.SqlDriver actual fun createInMemoryDatabase(): AppDatabase { val driver: SqlDriver = NativeSqliteDriver( schema = AppDatabase.Schema, name = null // null = in-memory ) return AppDatabase(driver) }

Testing network requests with Ktor MockEngine

Mock API responses without hitting real servers:
kotlin
// shared/src/commonTest/kotlin/data/ApiClientTest.kt import io.ktor.client.* import io.ktor.client.engine.mock.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals class ApiClientTest { @Test fun `fetch user returns parsed response`() = runTest { // Setup mock engine val mockEngine = MockEngine { request -> when (request.url.encodedPath) { "/api/users/123" -> { respond( content = """{"id":"123","name":"John Doe","email":"john@example.com"}""", status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json") ) } else -> respond("Not Found", HttpStatusCode.NotFound) } } // Create test client val httpClient = HttpClient(mockEngine) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } val apiClient = ApiClient(httpClient) // Execute test val user = apiClient.fetchUser("123") assertEquals("123", user.id) assertEquals("John Doe", user.name) assertEquals("john@example.com", user.email) } @Test fun `fetch user handles 404 error`() = runTest { val mockEngine = MockEngine { request -> respond("User not found", HttpStatusCode.NotFound) } val httpClient = HttpClient(mockEngine) { install(ContentNegotiation) { json() } } val apiClient = ApiClient(httpClient) // Should throw or return error result val result = apiClient.fetchUserOrNull("999") assertEquals(null, result) } @Test fun `create user sends correct request body`() = runTest { var capturedRequest: HttpRequestData? = null val mockEngine = MockEngine { request -> capturedRequest = request respond( content = """{"id":"124","name":"Jane Doe","email":"jane@example.com"}""", status = HttpStatusCode.Created, headers = headersOf(HttpHeaders.ContentType, "application/json") ) } val httpClient = HttpClient(mockEngine) { install(ContentNegotiation) { json() } } val apiClient = ApiClient(httpClient) // Create user apiClient.createUser( name = "Jane Doe", email = "jane@example.com" ) // Verify request assertEquals(HttpMethod.Post, capturedRequest?.method) assertEquals("/api/users", capturedRequest?.url?.encodedPath) } }

Mocking dependencies: MockK and Mockative

When you need to mock interfaces or classes, you have two main options for KMP:

Option 1: Manual fakes (recommended for most cases)

Create simple fake implementations in commonTest. This is what we did with FakeOrderApi earlier:
kotlin
// Simple, explicit, works everywhere class FakePaymentGateway : PaymentGateway { var chargeWasCalled = false var lastChargedAmount: Money? = null var shouldSucceed = true override suspend fun charge( amount: Money, method: PaymentMethod, metadata: Map<String, String> ): PaymentGatewayResult { chargeWasCalled = true lastChargedAmount = amount return if (shouldSucceed) { PaymentGatewayResult.Success("txn-123") } else { PaymentGatewayResult.Declined("Insufficient funds") } } }
Benefits: Simple, explicit, works on all platforms, easy to debug.
Drawbacks: More boilerplate for complex interfaces.

Option 2: Mockative (for complex scenarios)

Mockative is a KMP-compatible mocking library:
kotlin
import io.mockative.* import kotlinx.coroutines.test.runTest import kotlin.test.Test class PaymentProcessorTestWithMocks { @Mock val paymentGateway = mock<PaymentGateway>() @Mock val analytics = mock<Analytics>() @BeforeTest fun setup() { // Reset mocks } @Test fun `successful payment tracks analytics event`() = runTest { // Setup mocks given(paymentGateway) .suspendFunction(paymentGateway::charge) .whenInvokedWith(any(), any(), any()) .thenReturn(PaymentGatewayResult.Success("txn-123")) // Execute val processor = PaymentProcessor(paymentGateway, analytics) processor.processPayment( order = testOrder, paymentMethod = testPaymentMethod ) // Verify verify(analytics) .function(analytics::trackEvent) .with(eq("payment_success"), any()) .wasInvoked(exactly = once) } }
Benefits: Less boilerplate, familiar syntax for developers from JVM world.
Drawbacks: Additional dependency, generated code, can be harder to debug.

Testing expect/actual implementations

Here's the tricky part: how do you test code that has different implementations per platform?

Strategy 1: Test the contract in commonTest

Write tests that validate the behavior contract, regardless of implementation:
kotlin
// shared/src/commonMain/kotlin/platform/DateFormatter.kt expect class DateFormatter() { fun formatDate(timestamp: Long, pattern: String): String } // shared/src/commonTest/kotlin/platform/DateFormatterTest.kt class DateFormatterTest { private val formatter = DateFormatter() @Test fun `formatDate produces non-empty string`() { val result = formatter.formatDate( timestamp = 1704067200000, // 2024-01-01 00:00:00 pattern = "yyyy-MM-dd" ) // Test the contract, not the exact format assertTrue(result.isNotEmpty()) assertTrue(result.contains("2024")) } @Test fun `formatDate handles zero timestamp`() { val result = formatter.formatDate( timestamp = 0, pattern = "yyyy-MM-dd HH:mm:ss" ) // Should not crash assertTrue(result.isNotEmpty()) } }
This test runs on both platforms and validates that the contract works, even if the exact output differs slightly.

Strategy 2: Test platform-specific behavior in platform tests

For precise platform behavior, write platform-specific tests:
kotlin
// shared/src/androidUnitTest/kotlin/platform/DateFormatterTest.kt class DateFormatterAndroidTest { @Test fun `formatDate uses Android SimpleDateFormat`() { val formatter = DateFormatter() val result = formatter.formatDate( timestamp = 1704067200000, pattern = "yyyy-MM-dd" ) // Android-specific assertion assertEquals("2024-01-01", result) } } // shared/src/iosTest/kotlin/platform/DateFormatterTest.kt class DateFormatterIosTest { @Test fun `formatDate uses iOS NSDateFormatter`() { val formatter = DateFormatter() val result = formatter.formatDate( timestamp = 1704067200000, pattern = "yyyy-MM-dd" ) // iOS-specific assertion assertEquals("2024-01-01", result) } }

Testing Compose Multiplatform UI

If you're using Compose Multiplatform for shared UI, you can write UI tests in commonTest too:
kotlin
// shared/src/commonTest/kotlin/ui/LoginScreenTest.kt import androidx.compose.ui.test.* import kotlin.test.Test class LoginScreenTest { @Test fun `login screen displays email and password fields`() = runComposeUiTest { setContent { LoginScreen( onLogin = { _, _ -> } ) } onNodeWithTag("email_field").assertExists() onNodeWithTag("password_field").assertExists() onNodeWithTag("login_button").assertExists() } @Test fun `login button is disabled with empty fields`() = runComposeUiTest { setContent { LoginScreen( onLogin = { _, _ -> } ) } onNodeWithTag("login_button").assertIsNotEnabled() } @Test fun `entering valid credentials enables login button`() = runComposeUiTest { setContent { LoginScreen( onLogin = { _, _ -> } ) } onNodeWithTag("email_field").performTextInput("user@example.com") onNodeWithTag("password_field").performTextInput("password123") onNodeWithTag("login_button").assertIsEnabled() } @Test fun `clicking login calls callback with credentials`() = runComposeUiTest { var capturedEmail: String? = null var capturedPassword: String? = null setContent { LoginScreen( onLogin = { email, password -> capturedEmail = email capturedPassword = password } ) } onNodeWithTag("email_field").performTextInput("user@example.com") onNodeWithTag("password_field").performTextInput("password123") onNodeWithTag("login_button").performClick() assertEquals("user@example.com", capturedEmail) assertEquals("password123", capturedPassword) } }
This is huge: Your UI tests run on both Android and iOS. One test suite validates your entire UI layer.

Running tests: Commands and CI/CD

Running tests locally

bash
# Run all tests on all platforms ./gradlew allTests # Run only common (shared) tests ./gradlew :shared:cleanAllTests :shared:allTests # Run Android tests specifically ./gradlew :shared:testDebugUnitTest # Run iOS tests specifically (requires macOS) ./gradlew :shared:iosSimulatorArm64Test # Run tests with coverage ./gradlew :shared:koverHtmlReport

CI/CD configuration with GitHub Actions

Here's a production-ready CI workflow that runs KMP tests:
yaml
# .github/workflows/test.yml name: Run Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test-android: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Run Android tests run: ./gradlew :shared:testDebugUnitTest - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: android-test-results path: shared/build/reports/tests/ test-ios: runs-on: macos-14 # Apple Silicon runner steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Run iOS tests run: ./gradlew :shared:iosSimulatorArm64Test - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: ios-test-results path: shared/build/reports/tests/ code-coverage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Run tests with coverage run: ./gradlew :shared:koverHtmlReport - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: files: shared/build/reports/kover/html/index.html fail_ci_if_error: true

Testing best practices from production KMP apps

1. Write tests in commonTest by default

Rule: Always start by writing tests in commonTest. Only move to platform-specific test directories when absolutely necessary (e.g., testing platform-specific UI or APIs).
Why: Tests in commonTest run on all platforms automatically, giving you maximum coverage with minimum code.

2. Prefer fakes over mocks

Rule: Create simple fake implementations instead of using mocking frameworks. Reserve mocks for complex scenarios.
Why: Fakes are easier to debug, work on all platforms without code generation, and make tests more readable.

3. Test the contract, not the implementation

Rule: For expect/actual code, test that the contract is fulfilled, not specific implementation details.
Why: Implementations differ by platform. Testing the contract ensures your code works correctly everywhere.

4. Use Turbine for Flow testing

Rule: Always use Turbine when testing Kotlin Flows. Don't try to collect manually.
Why: Manual Flow collection in tests is error-prone and leads to flaky tests. Turbine handles timing and cancellation correctly.

5. Test error cases thoroughly

Rule: For every happy path test, write at least one error case test.
Why: Bugs in error handling are hard to catch in production. Test them early and often.

6. Use in-memory databases for tests

Rule: When testing database code, always use in-memory databases, never files.
Why: In-memory databases are fast, isolated, and don't leave artifacts. Perfect for tests.

7. Tag test nodes in Compose UI

Rule: Always add testTag modifiers to interactive Compose UI elements.
Why: Test tags make UI tests stable across refactoring. Text-based selectors break when copy changes.

Common KMP testing pitfalls (and how to avoid them)

Pitfall 1: Not testing on all platforms

The problem: Developers write tests but only run them on Android during local development.
The consequence: iOS-specific bugs slip through. Tests pass locally but fail in CI.
The solution: Configure your IDE to run tests on both platforms. Set up CI to enforce both test suites.

Pitfall 2: Testing implementation instead of behavior

The problem: Tests check internal implementation details instead of observable behavior.
The consequence: Refactoring breaks tests even when behavior didn't change.
The solution: Test inputs and outputs. Don't test private methods or internal state unless absolutely necessary.

Pitfall 3: Flaky Flow tests

The problem: Flow tests randomly fail due to timing issues.
The consequence: Developers stop trusting the test suite and ignore failures.
The solution: Use Turbine + runTest from kotlinx-coroutines-test. Never use delay() to wait for emissions.

Pitfall 4: Forgetting to test edge cases

The problem: Tests cover happy paths but miss null values, empty lists, network errors, etc.
The consequence: Production crashes that never happened in testing.
The solution: For every test, ask: "What could go wrong?" Test those scenarios.

Measuring test effectiveness: Coverage and quality

Code coverage with Kover

Kover is JetBrains' code coverage tool for Kotlin Multiplatform:
kotlin
// build.gradle.kts (project level) plugins { id("org.jetbrains.kotlinx.kover") version "0.7.5" } // build.gradle.kts (shared module) kover { reports { filters { excludes { // Exclude generated code classes("*.*BuildConfig*") classes("*.di.*") // DI code // Exclude platform-specific code if needed packages("com.example.platform") } } } }
Generate coverage reports:
bash
# Generate HTML coverage report ./gradlew koverHtmlReport # Open report in browser open shared/build/reports/kover/html/index.html # Generate XML for CI tools ./gradlew koverXmlReport

Coverage targets that make sense

Don't chase 100% coverage. Here are realistic targets based on production KMP apps:
  • Domain layer (business logic): 80-90% coverage
  • Data layer (repositories, API clients): 70-80% coverage
  • Presentation layer (ViewModels): 60-70% coverage
  • UI layer: 30-40% coverage (critical paths only)
  • Overall project: 65-75% coverage is excellent
Focus on quality, not quantity. One well-written test that catches real bugs is worth more than ten tests that check trivial getters.

Advanced testing: Screenshot testing and visual regression

For Compose Multiplatform UI, you can implement screenshot tests:
kotlin
// shared/src/commonTest/kotlin/ui/ScreenshotTests.kt import androidx.compose.ui.test.* import kotlin.test.Test class LoginScreenScreenshotTest { @Test fun `login screen matches design`() = runComposeUiTest { setContent { AppTheme { LoginScreen( onLogin = { _, _ -> } ) } } // Take screenshot and compare with baseline onRoot().captureToImage().assertAgainstGolden("login_screen") } @Test fun `login screen with error matches design`() = runComposeUiTest { setContent { AppTheme { LoginScreen( errorMessage = "Invalid credentials", onLogin = { _, _ -> } ) } } onRoot().captureToImage().assertAgainstGolden("login_screen_error") } }
Tools for visual testing:
  • Paparazzi (Android-only, but works with KMP)
  • Showkase (Component preview library)
  • Custom solutions using Compose UI testing APIs

Real-world testing: What companies actually test

Let's look at what successful KMP companies prioritize in their test suites:

Cash App: Financial accuracy above all

What they test most heavily:
  • Money formatting and calculations (100% coverage required)
  • Currency conversion logic
  • Payment validation rules
  • Transaction state machines
Key insight: "Any bug in money handling costs us real money. We test financial logic more thoroughly than anything else."

McDonald's: Payment processing reliability

What they test most heavily:
  • Order calculation logic (taxes, discounts, customizations)
  • Payment gateway integration
  • Offline order queueing
  • Menu data synchronization
Key insight: "We process 6.5 million monthly purchases. Integration tests for payment flows are non-negotiable."

Netflix: Data synchronization correctness

What they test most heavily:
  • Production data sync logic
  • Conflict resolution algorithms
  • API client retry mechanisms
  • Offline mode data handling
Key insight: "Production schedules can't be lost. We test sync logic exhaustively with every edge case we can think of."

Your KMP testing checklist

Use this checklist when setting up testing for your KMP project:

✅ Setup (do this once)

  • ☐ Add kotlin.test to commonTest dependencies
  • ☐ Add Kotest for better assertions
  • ☐ Add Turbine for Flow testing
  • ☐ Add kotlinx-coroutines-test for runTest
  • ☐ Configure Kover for code coverage
  • ☐ Set up CI to run tests on both platforms
  • ☐ Create commonTest source set structure

✅ For each feature (do this every time)

  • ☐ Write unit tests for business logic in commonTest
  • ☐ Test happy paths AND error cases
  • ☐ Test edge cases (nulls, empty, boundaries)
  • ☐ Write integration tests for platform interactions
  • ☐ Test async code with Turbine and runTest
  • ☐ Run tests on both Android and iOS before committing
  • ☐ Verify test coverage with Kover

✅ Before release (do this every release)

  • ☐ All tests pass on both platforms in CI
  • ☐ Code coverage meets your targets
  • ☐ No flaky tests in the suite
  • ☐ Critical user flows have UI tests
  • ☐ Error handling is tested thoroughly

Tools and libraries: The KMP testing ecosystem

Here's your complete toolbox for KMP testing in 2025:

Core Testing

  • kotlin.test - Standard testing annotations, works everywhere
  • kotlinx-coroutines-test - runTest, test dispatchers, virtual time
  • Kotest - Expressive assertions and multiple testing styles

Async Testing

  • Turbine - Flow testing made simple (by Cash App!)
  • kotlinx-coroutines-test - Test coroutine timing and dispatchers

Mocking

  • Mockative - KMP-compatible mocking framework
  • Manual fakes - Simple fake implementations (often better!)

Database Testing

  • SQLDelight - Built-in test helpers for database queries
  • In-memory drivers - Fast, isolated database tests

Network Testing

  • Ktor MockEngine - Mock HTTP responses without real servers
  • kotlinx.serialization - Test JSON parsing and serialization

UI Testing

  • Compose Multiplatform UI Testing - Shared UI tests for Compose
  • Paparazzi - Screenshot testing (Android)
  • Espresso - Android UI testing
  • XCUITest - iOS UI testing

Code Coverage

  • Kover - Official Kotlin code coverage tool
  • JaCoCo - Works for Android, not for iOS

The bottom line: Why KMP testing is a competitive advantage

Companies that ship KMP in production don't do it just for code sharing. They do it for test sharing.
When McDonald's writes a test for payment validation, that test runs on Android and iOS. One test. Both platforms. Zero duplication.
When Cash App fixes a bug in money formatting, they write one test that ensures the bug never returns on any platform.
When Netflix validates their data sync logic, they write comprehensive tests once and know those tests protect both iOS and Android users.

The math is simple:

Without KMP:
  • Write feature for Android: 5 hours
  • Write tests for Android: 2 hours
  • Write feature for iOS: 5 hours
  • Write tests for iOS: 2 hours
  • Total: 14 hours
With KMP:
  • Write shared feature: 6 hours
  • Write shared tests: 3 hours
  • Platform-specific UI (both): 3 hours
  • Total: 12 hours
But the real savings come later:
  • Bug found in production: Fix once, test once, deploy to both platforms
  • Refactoring: Tests run on both platforms automatically
  • New team member: One test suite to learn, not two
  • Confidence: If tests pass on both platforms, you ship with confidence

Ready to test like the pros?

You've seen how Cash App, McDonald's, and Netflix test their production KMP code. You've learned the patterns, tools, and strategies.
But here's the hard part: setting up a KMP project with proper testing infrastructure takes time. You need:
  • Gradle configuration with correct test dependencies
  • CI/CD pipelines that run tests on both platforms
  • Project structure that makes testing easy
  • Sample tests demonstrating best practices
  • Mock implementations for APIs and databases
You could spend days configuring this from scratch. Or you could start with it already done.

KMPShip: Production-ready testing from day one

Skip the testing setup headaches. Start with a fully-tested codebase.

✅ Complete test suite included:

  • 📝 Unit tests for business logic
  • 🔗 Integration tests for APIs and database
  • 🎨 UI tests for Compose Multiplatform
  • 🤖 CI/CD with automated testing

🚀 Testing best practices built-in:

  • ✅ Kotest + Turbine + MockK configured
  • 📊 Code coverage with Kover
  • 🏗️ Testable architecture patterns
  • 📚 Example tests you can copy
Write features with confidence. Ship bugs to CI, not production.
Join developers shipping tested KMP apps to production

Sources and references

Official documentation:

Testing guides and tutorials:

Production testing experiences:

Testing tools:


Note: Code examples are simplified for clarity but represent actual testing patterns used in production KMP applications. All recommendations are based on official documentation and real-world usage.



Continue your Kotlin Multiplatform journey

Want to learn more about KMP development?


Quick Questions About KMP Testing

Still have questions about testing Kotlin Multiplatform apps? Get instant answers:

Skip months of testing setup

Start with a fully-tested KMP project. Write features, not test infrastructure.