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 testsWriting 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:iosArm64TestGradle 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:iosSimulatorArm64TestDetecting 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— thencommonTestcovers 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