iOS/Android Shared Business Logic Testing in KMP

iOS/Android Shared Business Logic Testing in KMP

Kotlin Multiplatform's promise is compelling: write business logic once, run it on iOS and Android. But "write once" doesn't automatically mean "test once." Shared code can behave differently across platforms due to threading models, memory management, and standard library differences.

This guide covers practical strategies for testing shared business logic across both iOS and Android targets.

What Counts as "Business Logic"?

Before writing any tests, be clear about what belongs in shared code:

Test thoroughly in commonTest:

  • Domain models and their validation
  • Business rules and calculations
  • State machine logic
  • Data transformation and mapping
  • Repository interfaces and their use cases
  • Caching strategies
  • Error handling flows

Test in platform-specific test sets:

  • Platform-specific implementations (database drivers, network engines)
  • UI state management tied to platform APIs
  • Native SDK wrappers

Don't test in shared code:

  • UI components (composables, UIViews)
  • Platform lifecycle (Activity, ViewController)
  • Native APIs (notifications, camera, GPS)

Project Layout for Shared Testing

shared/
├── src/
│   ├── commonMain/kotlin/
│   │   ├── domain/
│   │   │   ├── model/         # Data classes, entities
│   │   │   ├── repository/    # Repository interfaces
│   │   │   └── usecase/       # Business use cases
│   │   └── data/
│   │       ├── api/           # Ktor client code
│   │       └── cache/         # Caching logic
│   ├── commonTest/kotlin/
│   │   ├── domain/            # Tests for ALL business logic
│   │   └── data/              # Tests for API/cache code
│   ├── androidMain/kotlin/    # Android-specific implementations
│   ├── androidUnitTest/kotlin/ # Android-only unit tests
│   ├── iosMain/kotlin/        # iOS-specific implementations
│   └── iosTest/kotlin/        # iOS-only tests

Writing Cross-Platform Domain Tests

A domain-heavy example: an e-commerce cart:

// commonMain
data class CartItem(val productId: String, val name: String, val price: Long, val quantity: Int)

class Cart {
    private val _items = mutableListOf<CartItem>()
    val items: List<CartItem> get() = _items.toList()

    val totalCents: Long get() = _items.sumOf { it.price * it.quantity }
    val itemCount: Int get() = _items.sumOf { it.quantity }

    fun addItem(item: CartItem) {
        val existing = _items.indexOfFirst { it.productId == item.productId }
        if (existing >= 0) {
            _items[existing] = _items[existing].copy(
                quantity = _items[existing].quantity + item.quantity
            )
        } else {
            _items.add(item)
        }
    }

    fun removeItem(productId: String) {
        _items.removeAll { it.productId == productId }
    }

    fun clear() {
        _items.clear()
    }

    fun applyDiscount(percent: Int): Long {
        require(percent in 0..100) { "Discount must be 0-100%" }
        return totalCents * (100 - percent) / 100
    }
}

Tests in commonTest — these run on both Android JVM and iOS Kotlin/Native:

// commonTest
import kotlin.test.*

class CartTest {
    private lateinit var cart: Cart

    @BeforeTest
    fun setup() {
        cart = Cart()
    }

    @Test
    fun `starts empty`() {
        assertEquals(0, cart.itemCount)
        assertEquals(0L, cart.totalCents)
        assertTrue(cart.items.isEmpty())
    }

    @Test
    fun `adds item correctly`() {
        cart.addItem(CartItem("p1", "Widget", 1000L, 2))
        assertEquals(2, cart.itemCount)
        assertEquals(2000L, cart.totalCents)
    }

    @Test
    fun `accumulates quantity for duplicate product`() {
        cart.addItem(CartItem("p1", "Widget", 1000L, 1))
        cart.addItem(CartItem("p1", "Widget", 1000L, 3))
        assertEquals(1, cart.items.size) // single line item
        assertEquals(4, cart.itemCount)  // 4 units
        assertEquals(4000L, cart.totalCents)
    }

    @Test
    fun `removes item by product id`() {
        cart.addItem(CartItem("p1", "Widget", 1000L, 2))
        cart.addItem(CartItem("p2", "Gadget", 2000L, 1))
        cart.removeItem("p1")
        assertEquals(1, cart.items.size)
        assertEquals("p2", cart.items.first().productId)
    }

    @Test
    fun `calculates total across multiple items`() {
        cart.addItem(CartItem("p1", "Widget", 999L, 3))   // 2997
        cart.addItem(CartItem("p2", "Gadget", 4999L, 1))  // 4999
        assertEquals(7996L, cart.totalCents)
    }

    @Test
    fun `applies discount correctly`() {
        cart.addItem(CartItem("p1", "Widget", 10000L, 1))
        val discounted = cart.applyDiscount(20)
        assertEquals(8000L, discounted)
    }

    @Test
    fun `rejects invalid discount`() {
        cart.addItem(CartItem("p1", "Widget", 10000L, 1))
        assertFailsWith<IllegalArgumentException> {
            cart.applyDiscount(101)
        }
        assertFailsWith<IllegalArgumentException> {
            cart.applyDiscount(-5)
        }
    }

    @Test
    fun `clears all items`() {
        cart.addItem(CartItem("p1", "Widget", 1000L, 2))
        cart.addItem(CartItem("p2", "Gadget", 2000L, 1))
        cart.clear()
        assertTrue(cart.items.isEmpty())
        assertEquals(0L, cart.totalCents)
    }
}

Platform-Specific Pitfalls

Threading on Kotlin/Native (iOS)

Before Kotlin 1.7.20 and kotlinx-coroutines 1.6.4, Kotlin/Native enforced strict object ownership. Mutable objects could not be shared between threads without freezing, causing InvalidMutabilityException.

With the new memory model (now the default), these restrictions are lifted. Ensure your versions are current:

// build.gradle.kts
kotlin("multiplatform") version "1.9.22"

// commonMain dependencies
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")

If you're on older versions, objects passed between coroutines on iOS may throw. Upgrade first.

Number Types

Long behaves consistently across platforms, but Int overflow behavior is the same as JVM on Android and similar on iOS. Be explicit about numeric types in domain models.

Collections

kotlin.collections is cross-platform and behaves consistently. Avoid java.util.* in commonMain — it doesn't exist on Kotlin/Native.

// Wrong — doesn't compile on iOS
import java.util.LinkedList
val queue = LinkedList<String>()

// Correct — works everywhere
val queue = ArrayDeque<String>()

String Formatting

String.format() is JVM-only. Use string templates instead:

// Wrong on iOS:
val message = String.format("Total: $%.2f", total / 100.0)

// Correct:
val dollars = total / 100
val cents = total % 100
val message = "Total: \$$dollars.${cents.toString().padStart(2, '0')}"

Or use kotlinx-datetime for date formatting (it's multiplatform).

Asynchronous Business Logic Testing

Many business rules involve async operations — loading data, syncing, background processing:

// commonMain
class SyncManager(
    private val localRepo: LocalRepository,
    private val remoteApi: RemoteApi,
    private val clock: Clock
) {
    var lastSyncTime: Long? = null
        private set

    suspend fun sync(): SyncResult {
        return try {
            val remoteData = remoteApi.fetchAll()
            localRepo.saveAll(remoteData)
            lastSyncTime = clock.now()
            SyncResult.Success(remoteData.size)
        } catch (e: Exception) {
            SyncResult.Failure(e.message ?: "Unknown error")
        }
    }

    suspend fun syncIfStale(maxAgeMs: Long): SyncResult? {
        val last = lastSyncTime ?: return sync()
        if (clock.now() - last > maxAgeMs) return sync()
        return null
    }
}
// commonTest
class SyncManagerTest {
    private val fakeApi = FakeRemoteApi()
    private val fakeRepo = FakeLocalRepository()
    private val fakeClock = FakeClock(startTime = 1_000_000L)
    private lateinit var manager: SyncManager

    @BeforeTest
    fun setup() {
        manager = SyncManager(fakeRepo, fakeApi, fakeClock)
    }

    @Test
    fun `syncs successfully and records time`() = runTest {
        fakeApi.data = listOf(Item("a"), Item("b"), Item("c"))
        val result = manager.sync()
        assertIs<SyncResult.Success>(result)
        assertEquals(3, result.count)
        assertEquals(3, fakeRepo.savedItems.size)
        assertEquals(1_000_000L, manager.lastSyncTime)
    }

    @Test
    fun `handles api failure gracefully`() = runTest {
        fakeApi.shouldFail = true
        val result = manager.sync()
        assertIs<SyncResult.Failure>(result)
        assertTrue(fakeRepo.savedItems.isEmpty())
        assertNull(manager.lastSyncTime)
    }

    @Test
    fun `skips sync when data is fresh`() = runTest {
        fakeApi.data = listOf(Item("a"))
        manager.sync() // initial sync at t=1_000_000

        fakeApi.callCount = 0
        fakeClock.advance(30_000L) // advance 30 seconds
        val result = manager.syncIfStale(maxAgeMs = 60_000L)

        assertNull(result) // skipped
        assertEquals(0, fakeApi.callCount)
    }

    @Test
    fun `re-syncs when data is stale`() = runTest {
        fakeApi.data = listOf(Item("a"))
        manager.sync() // initial sync

        fakeClock.advance(120_000L) // advance 2 minutes
        val result = manager.syncIfStale(maxAgeMs = 60_000L)

        assertNotNull(result)
        assertIs<SyncResult.Success>(result)
    }
}

Running Tests on iOS

To run commonTest on iOS (Kotlin/Native), you need macOS and Xcode:

# Run on iOS simulator
./gradlew :shared:iosSimulatorArm64Test

<span class="hljs-comment"># Run on actual device (needs provisioning)
./gradlew :shared:iosArm64Test

Gradle shells out to xcodebuild for iOS execution. A simulator must be booted:

# List available simulators
xcrun simctl list devices

<span class="hljs-comment"># Boot one
xcrun simctl boot <span class="hljs-string">"iPhone 15 Pro"

In CI (GitHub Actions):

test-ios:
  runs-on: macos-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-java@v4
      with:
        distribution: temurin
        java-version: 17
    - name: Boot iOS Simulator
      run: |
        xcrun simctl boot "iPhone 15 Pro" || true
    - name: Run iOS tests
      run: ./gradlew :shared:iosSimulatorArm64Test

Detecting Platform-Specific Failures

Use expect/actual to mark platform-specific test helpers:

// commonTest
expect fun runPlatformTest(block: suspend () -> Unit)

// androidUnitTest
actual fun runPlatformTest(block: suspend () -> Unit) {
    runBlocking { block() }
}

// iosTest (via Kotlin/Native)
actual fun runPlatformTest(block: suspend () -> Unit) {
    runBlocking { block() }
}

This lets you write one test body that runs through the correct mechanism per platform.

End-to-End Validation

commonTest ensures your business logic is correct in isolation. But it won't catch:

  • The iOS app failing to launch on a specific OS version
  • An Android-specific serialization bug in production data
  • A server API change breaking your shared client code

HelpMeTest provides continuous end-to-end testing for your live iOS and Android apps. Write test scenarios in plain English, and HelpMeTest runs them 24/7 — catching regressions that shared unit tests can't see.

Summary

  • Put business logic in commonMain — then commonTest covers both iOS and Android
  • Avoid JVM-only APIs in shared code: java.util.*, String.format(), Thread
  • Use the new Kotlin/Native memory model (Kotlin 1.7.20+) to avoid freezing issues
  • Test async logic with runTest — it controls virtual time on all platforms
  • Run on iOS in CI — macOS runners, Xcode, and a booted simulator are required
  • Use fakes over mocks for cross-platform dependency injection in tests
  • Complement with integration and E2E tests for full-stack coverage

Read more