Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

Second Edition · Android 14, iOS 17, Desktop · Kotlin 1.9.10 · Android Studio Hedgehog

8. Testing
Written by Saeed Taheri

Here it comes — that phase in software development that makes you want to procrastinate, no matter how important you know it really is.

Whether you like it or not, having a good set of tests — both automated and manual — ensures the quality of your software. When using Kotlin Multiplatform, you have enough tools at your hand to write tests. So if you’re thinking of letting it slide this time, you’ll have to come up with another excuse. :]

Setting Up the Dependencies

Testing your code in the KMP world follows the same pattern you’re now familiar with. You test the code in the common module. You may also need to use the expect/actual mechanism as well. With this in mind, setting up the dependencies is structurally the same as it is with non-test code.

From the starter project, using Android Studio, open the build.gradle.kts file inside the shared module. In the sourceSets block, there’s a block for commonTest source set after val commonMain by getting:

val commonTest by getting {
  dependencies {
    implementation(kotlin("test"))
  }
}

This is a dependency on the kotlin.test library. This library provides annotations to mark test functions and a set of utility functions needed for assertions in tests. Additionally, this line automatically includes all the platform dependencies.

Do a Gradle sync if required.

As you declared above, your test codes will be inside the commonTest folder. Create it as a sibling directory to commonMain by right-clicking the src folder inside the shared module and choosing New ▸ Directory. Once you start typing commonTest, Android Studio will provide you with autocompletion. Choose commonTest/kotlin.

Fig. 8.1 — Create a new directory in Android Studio
Fig. 8.1 — Create a new directory in Android Studio

Fig. 8.2 — Android Studio suggests naming the test directory
Fig. 8.2 — Android Studio suggests naming the test directory

Note: Although not necessary, it’s a good practice to have your test files in the same package structure as your main code. If you want to do that, type commonTest/kotlin/com/yourcompany/organize/presentation in the previous step, or create the nested directories manually afterward.

Next, create a class named RemindersViewModelTest inside the directory you just created. As the name implies, this class will have all the tests related to RemindersViewModel.

Now it’s time to create the very first test function for the app. Add this inside the newly created class:

@Test
fun testCreatingReminder() {
}

You’ll implement the function body later. The point to notice is the @Test annotation. It comes from the kotlin.test library. Make sure to import the needed package at the top of the file if Android Studio didn’t do it automatically for you: import kotlin.test.Test.

As soon as you add a function with @Test annotation to the class, Android Studio shows run buttons in the code gutter to make it easier for you to run the tests.

Fig. 8.3 — Run button for tests in code gutter
Fig. 8.3 — Run button for tests in code gutter

You can run the tests by clicking on those buttons, using commands in the terminal, or by pressing the keyboard shortcut Control-Shift-R on Mac or Control-Shift-F10 on Windows and Linux.

Fig. 8.4 — Choosing test platform
Fig. 8.4 — Choosing test platform

As an example, choose android to run the test on Android.

Congratulations! You ran your first test successfully.

Fig. 8.5 — First test successful
Fig. 8.5 — First test successful

When you ask the system to run the test on any platform, it needs to find a test library on that platform to run your tests on. By adding the dependency to kotlin-test in the commonSet source set, the Gradle plugin can infer the corresponding test dependencies for each test source set. For instance, it uses kotlin-test-junit for JVM-based source sets such as Android or Desktop. Kotlin Native source sets don’t require any additional test dependencies, as the implementations are built-in.

Writing Tests for RemindersViewModel

With the dependencies for unit testing all in place, it’s time to create some useful test functions.

Since you’re testing the viewModel, you require an instance of RemindersViewModel at hand. Add a lateinit property in the class for this matter as follows:

private lateinit var viewModel: RemindersViewModel

Next, you need to somehow initialize this property. When writing tests, you can tag a function with @BeforeTest annotation. This will make sure that the specific function runs before every test in the class. That seems a good place to set up the viewModel.

Add this function to the class:

@BeforeTest
fun setup() {
  viewModel = RemindersViewModel()
}

Note: There’s a @AfterTest annotation as well. As the name implies, it runs after each test in the class. You can use functions tagged with this annotation to do any needed cleanups.

As for the body of testCreatingReminder(), update it with:

@Test
fun testCreatingReminder() {
  //1
  val title = "New Title"

  //2
  viewModel.createReminder(title)

  //3
  val count = viewModel.reminders.count {
    it.title == title
  }

  //4
  assertTrue(
    actual = count == 1,
    message = "Reminder with title: $title wasn't created.",
  )
}
  1. First, you create a title constant.
  2. You use the createReminder method of the viewModel to create a new reminder.
  3. Next, you check the number of items in reminders property of the viewModel having the title you used. If you faced an error about the visibility of reminders, don’t worry. You’ll fix it soon.
  4. kotlin.test library includes several assert functions, which you can take advantage of. Here, you’re using assertTrue to check if count equals 1. If that’s true, it means the creation process was successful. If not, you show a message in the console.

The reminders property in RemindersViewModel was private when you wrote it. Since commonTest is in the same module as commonMain, you can change the visibility modifier for that property to internal. This way, outsiders using the shared module such as androidApp and iosApp won’t see any changes and the property would be visible to your test functions.

Open RemindersViewModel.kt and change the aforementioned property to this:

internal val reminders: List<Reminder>
  get() = repository.reminders

Now it’s time to run the test. To run the tests on all platforms at once, you can try either of these actions:

  • Choose allTests from the list of tasks in the Gradle pane in Android Studio.

Fig. 8.6 — Choosing allTests from Gradle pane
Fig. 8.6 — Choosing allTests from Gradle pane

  • Run the command ./gradlew :shared:allTests in Terminal while you’re in the working directory of the project.

Whatever option you pick, you will have a successful test for all platforms. Hooray!

Fig. 8.7 — Successful test for creating a reminder
Fig. 8.7 — Successful test for creating a reminder

Writing Tests for Platform

All implementation details of the RemindersViewModel class were inside the commonMain source set. However, the Platform class is a bit different. As you remember, Platform uses the expect/actual mechanism. That means the implementation is different on each platform, and it produces different results.

To address this matter, you need to have multiple test suites. Those would follow the source sets pattern you saw in previous steps. Create androidUnitTest, iosTest and desktopTest directories in the shared module. Don’t forget to add com/yourcompany/organize directories.

You have two choices: Either you use the same expect/actual mechanism for your test class, or you create the test classes independent of each other in each source set. In both methods, the system will run all the functions annotated with @Test. However, since expect/actual will force you to fulfill the expected test functions, it’s a safer choice from a structural standpoint.

In commonTest folder, create a class named PlatformTest under the com.yourcompany.organize package and define the class like this:

expect class PlatformTest {
  @Test
  fun testOperatingSystemName()
}

Here, you’re promising to implement a test function named testOperatingSystemName. You can add any test function, but for the sake of brevity, this is the only Platform test function you’ll see in this chapter.

You’ve heard a lot about how to create actual classes. If you aren’t yet comfortable enough with the process, go back and take a look at Chapter 6.

Android

Create PlatformTest.kt inside the directories you created earlier in androidTest and update as follows:

import kotlin.test.DefaultAsserter.assertEquals

actual class PlatformTest {
  private val platform = Platform()

  @Test
  actual fun testOperatingSystemName() {
    assertEquals(
      expected = "Android",
      actual = platform.osName,
      message = "The OS name should be Android."
    )
  }
}

Pretty straightforward, isn’t it? You assert that the operating system name should be “Android”.

However, this test will probably fail. As of writing this chapter, there’s an open issue at https://issuetracker.google.com/issues/191287536 where the tests on Android run as instrument tests instead of JUnit tests. This causes some problems. For instance, you’ll get some errors telling you that Build.SUPPORTED_ABIS shouldn’t be null, or you need to mock Resources.getSystem(). As a hacky workaround, you can edit the Platform implementation on Android to not instantiate problematic properties right away.

Open Platform.kt in androidMain module and change these values:

actual val cpuType =
  Build.SUPPORTED_ABIS?.firstOrNull() ?: "---"

actual val screen: ScreenInfo
  get() = ScreenInfo()

Now run the tests for Android again, and this particular test you wrote will pass successfully. However, it’s better to hope unit test issues go away soon.

iOS

Open build.gradle.kts for the shared module and add these lines to the sourceSets block.

val iosX64Test by getting
val iosArm64Test by getting
val iosSimulatorArm64Test by getting
val iosTest by creating {
  dependsOn(commonTest)
  iosX64Test.dependsOn(this)
  iosArm64Test.dependsOn(this)
  iosSimulatorArm64Test.dependsOn(this)
}

This helps you compile the tests written inside the iosTest source set.

Create PlatformTest.kt inside the directories you created earlier in iosTest and update as follows:

@kotlinx.cinterop.ExperimentalForeignApi
@kotlin.experimental.ExperimentalNativeApi
actual class PlatformTest {
  private val platform = Platform()

  @Test
  actual fun testOperatingSystemName() {
    assertTrue(
      actual = platform.osName.equals("iOS", ignoreCase = true)
        || platform.osName == "iPadOS",
      message = "The OS name should either be iOS or iPadOS."
    )
  }
}

You check if the OS name is either iOS or iPadOS.

Desktop

Create PlatformTest.kt inside the directories you created earlier in desktopMain and update as follows:

actual class PlatformTest {
  private val platform = Platform()

  @Test
  actual fun testOperatingSystemName() {
    assertTrue(
      actual = platform.osName.contains("Mac", ignoreCase = true)
        || platform.osName.contains("Windows", ignoreCase = true)
        || platform.osName.contains("Linux", ignoreCase = true)
        || platform.osName == "Desktop",
      message = "Non-supported operating system"
    )
  }
}

This is a bit difficult to test properly. For now, you can check if the reported OS name contains the app’s supported platforms. If not, let the test fail. If you run the allTests Gradle task as before, the system will run these tests as well. Try it to see a new batch of successful tests.

UI Tests

Until now, the approach you’ve followed in this book is to share the business logic in the shared module using Kotlin Multiplatform and create the UI in each platform using the available native toolkit. Consequently, you’ve been able to share the tests for the business logic inside the shared module as well.

For testing UI, you can safely assume that there’s no KMP in place. You test Android and desktop UIs using Compose Tests, and iOS UI using XCUITest.

Android

You created the UI for Organize entirely using Jetpack Compose. Testing Compose layouts is different from testing a View-based UI. The View-based UI toolkit defines what properties a View has, such as the rectangle it’s occupying, its properties and so forth. In Compose, some composables may emit UI into the hierarchy. Hence, you need a new matching mechanism for UI elements.

Fortunately, the creators of Jetpack Compose had this in mind and provided the necessary tools to test layouts.

Open build.gradle.kts within androidApp and add these inside the dependencies block:


androidTestImplementation(platform(libs.androidx.compose.bom))
debugImplementation(libs.androidx.ui.test.manifest)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.androidx.fragment.testing)
androidTestImplementation(libs.androidx.test.runner)

In the defaultConfig section of android block, add this to tell the system how to run the tests:

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Go ahead and sync your project now. Next, it’s time to create packages and files. Inside the src directory, create these nested folders: androidTest/java/com/yourcompany/organize/android/

You’re going to replicate the same package structure of the main directory.

Next, create a Kotlin file called AppUITest.kt and add the following code:

import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.yourcompany.organize.android.ui.root.MainActivity
import org.junit.Rule

class AppUITest {
  @get:Rule
  val composeTestRule = createAndroidComposeRule<MainActivity>()
}

In the code above, you add a property of type AndroidComposeTestRule. The createAndroidComposeRule creates a test rule for the activity you provide. It brings up the activity, so you can run your tests.

The very first test you’ll write is to check for the existence of the About button. As you remember, the button is in the top right corner of the app and has an i icon. The way you can match that element in your tests is through a mechanism named Semantics.

Semantics

Semantics give meaning to a piece of UI — whether it’s a simple button or a whole set of composables. The semantics framework is primarily there for accessibility purposes. However, tests can take advantage of the information exposed by semantics about the UI hierarchy.

You attach semantics to the composables through a Modifier.

Open RemindersView.kt in the androidApp module and attach a semantics modifier to the IconButton in the Toolbar compasable. The IconButton will look like this:

IconButton(
  onClick = onAboutButtonClick,
  modifier = Modifier.semantics { contentDescription = "aboutButton" },
) {
    Icon(
      imageVector = Icons.Outlined.Info,
      contentDescription = "About Device Button",
    )
}

You’re attaching semantics to the IconButton with the content description aboutButton.

Go back to AppUITest.kt and add the following functon:

@Test
fun testAboutButtonExistence() {
  composeTestRule
    .onNodeWithContentDescription("aboutButton")
    .assertIsDisplayed()
}

Using the composeTestRule you defined, you query a node with the content description you set, and assert if it’s displayed.

Run the test using the run button in the code gutter. You’ll see that the emulator or the connected device runs your app for an instant and then closes it.

If everything goes well, you should see the report for a passed test.

Fig. 8.8 — Successful test for about button existence
Fig. 8.8 — Successful test for about button existence

Next up is testing whether the About page opens and closes successfuly. Add the following function to test the same:

@Test
fun testOpeningAndClosingAboutPage() {
  //1
  composeTestRule
    .onNodeWithContentDescription("aboutButton")
    .performClick()

  //2
  composeTestRule
    .onNodeWithText("About Device")
    .assertIsDisplayed()

  //3
  composeTestRule
    .onNodeWithContentDescription("Up Button")
    .performClick()

  //4
  composeTestRule
    .onNodeWithText("Reminders")
    .assertIsDisplayed()
}

In this code:

  1. You find the About button using the semantics you defined and simulate performing a click on it.
  2. Check if there’s a text on the screen with About Device content. The About page has this title, and it’s only there if that page is onscreen. This is not a good way to do it, though. This test will fail if you localize your app in another language. Using semantics is always a better choice.
  3. If you’d set content description on buttons as you did with the Up Button in the toolbar, you can use that without setting and querying semantics. You find the button and perform a click on it.
  4. When you close the About page, the app should be in the Reminders page. Check for the page title if this is the case.

Run the test, and it will pass.

Desktop

As the UI code for Android and desktop are essentially the same, the tests will be very similar. The setup is a bit different, though. The code is already there for you in the starter project. These are the differences you should consider:

  • Test dependencies are different. Take a look at build.gradle.kts in desktopApp module.
val jvmTest by getting {
  dependencies {
    implementation(compose.desktop.uiTestJUnit4)
    implementation(compose.desktop.currentOs)
  }
}
  • As there’s no Activity on the desktop to host your tests, you need to lay the groundwork yourself. Open AppUITest.kt in desktopApp module. Essentially, there are two main differences:

    1. First, you should create createComposeRule differently and use the more generic createComposeRule() call.

    2. Second, there’s a setUp method which will run before all your tests. Here, you’re emiting a similar composable to the app you launch. The main difference is that there are no windows involved.

@Before
fun setUp() {
  composeTestRule.setContent {
    var screenState by remember { mutableStateOf(Screen.Reminders) }

    when (screenState) {
      Screen.Reminders ->
        RemindersView(
          onAboutButtonClick = { screenState = Screen.AboutDevice }
        )
      Screen.AboutDevice -> AboutView()
    }
  }
}
  • Since there are no windows in this test suite, the second test function will be like this:
@Test
fun testOpeningAboutPage() {
  //1
  composeTestRule
    .onNodeWithText("Reminders")
    .assertIsDisplayed()

  //2
  composeTestRule
    .onNodeWithContentDescription("aboutButton")
    .performClick()

  //3
  composeTestRule.waitForIdle()

  //4
  composeTestRule
    .onNodeWithContentDescription("aboutView")
    .assertIsDisplayed()
}
  1. First, you check if you’re in the Reminders page by asserting the existence of the Reminders title.
  2. You simulate a click on the About button.
  3. Next, wait for the recomposition to finish. When the Compose test rule was an Activity, it did this automatically. Now, it’s your job to make your tests wait.
  4. Lastly, check if an element with the semantics aboutView exists in the hierarchy.

Run your test suite to see them all pass.

iOS

To make the UI code testable in Xcode, you need to add a UI Test target to your project. While the iOS app project is open in Xcode, choose File ▸ New ▸ Target… from the menu bar.

Scroll down until you find UI Testing Bundle.

Fig. 8.9 — Xcode New Target Template
Fig. 8.9 — Xcode New Target Template

Click Next. While the default values for the target name and other options are usually fine, check that the information matches what’s in line with the Organize app. Set the Organization Identifier to com.yourcompany. For instance the bundle identifier suggested may be different. Click Finish to let Xcode create the UI test target for you.

Take a look at the file navigator. Xcode has created a folder with two test classes for you.

Fig. 8.10 — Xcode UI Test Target files
Fig. 8.10 — Xcode UI Test Target files

You can safely delete iosAppUITestsLaunchTests.swift. Open iosAppUITests.swift and delete all the contents of the class. You’re going to write a couple of test functions here.

First, store an instance of the app as a property in the test class.

private let app = XCUIApplication()

Second, override the setUp function. The system calls this method before running each test. It’s similar to when you tag a test function in Kotlin using @BeforeTest.

override func setUp() {
  continueAfterFailure = false
  app.launch()
}

This function will prevent the continuation of tests should any errors occur. Then launch the app, so you can run your tests.

Next, write a test to check the existence of the About button.

func testAboutButtonExistence() {
  XCTAssert(app.buttons["About"].exists)
}

The assert functions in Xcode test frameworks usually start with XCTAssert. This is the simplest one you could use, and it needs a Boolean parameter. Query all the buttons of the app and look for one with About title.

Note: Xcode test functions should start with test..., otherwise Xcode won’t identify them as test functions, and so won’t run them.

As with Android Studio, you can run the tests using the button in the code gutter. You can also choose the Test button from the Product menu or press Command-U.

You could improve this code a bit. Imagine you’ve localized your app in French. When you run the test above in French, the button title won’t be About, so the test will fail. You can easily resolve this.

Go to ContentView.swift and attach the below modifier to the Button element:

.accessibilityIdentifier("aboutButton")

The Button element will now be as follows:

Button {
  shouldOpenAbout = true
} label: {
  Label("About", systemImage: "info.circle")
    .labelStyle(.titleAndIcon)
}
.accessibilityIdentifier("aboutButton")
.popover(isPresented: $shouldOpenAbout) {
  AboutView()
    .frame(
      idealWidth: 350,
      idealHeight: 450
    )
}

From now on, you can refer to this specific button using aboutButton regardless of what its title is.

Next, you can change the test body to this:

XCTAssert(app.buttons["aboutButton"].exists)

This is similar to the semantics modifier in Jetpack Compose. Run your test again to confirm nothing has changed in behavior and result.

Recording UI tests

Xcode has a cool feature that you can take advantage of to make the process of creating UI tests easier.

Create a new test function and put the cursor in the empty body.

func testOpeningAndClosingAboutPage() {
  // Put the cursor here
}

At the bottom of the page, a Record button would appear. Click on it. The app will run on the simulator, and Xcode will turn whatever action you do in the app into code.

Fig. 8.11 — Xcode UI Test Record button
Fig. 8.11 — Xcode UI Test Record button

Do these actions in order:

  1. Tap the About button.
  2. When the About page comes up, tap the Done button.
  3. Stop recording using the same button with which you started recording.

Take a look at the test function. Xcode has added code for you. It will be something like this.

func testOpeningAndClosingAboutPage() {
  let app = XCUIApplication()
  app.navigationBars["Reminders"].buttons["aboutButton"].tap()
  app.navigationBars["About Device"].buttons["Done"].tap()
}

If that’s all you had in mind, you’re good to go! Otherwise, this gives you a starting point for writing your tests. You can also learn from this feature how to find elements on screen and act on them.

Another thing to take note of is that Xcode automatically goes for the accessibilityIdentifier if you’d set any. If not, it uses the static title to query elements. It’s a great practice to always set this modifier on elements.

That said, you can take cues from Xcode’s automatic test recording system and have this test function:

func testOpeningAndClosingAboutPage() {
  //1
  app.buttons["aboutButton"].tap()

  //2
  let aboutPageTitle = app.staticTexts["About Device"]
  XCTAssertTrue(aboutPageTitle.exists)

  //3
  app.navigationBars["About Device"].buttons["Done"].tap()

  //4
  let remindersPageTitle = app.staticTexts["Reminders"]
  XCTAssertTrue(remindersPageTitle.exists)
}
  1. Simulate tapping the About button when the app launches.
  2. Check if there’s a text on screen with About Device content. The About page has this title, and it’s only there if that page is onscreen.
  3. Find the Done button in one of the app’s navigation bars with an About Device title and try tapping on it.
  4. When you close the About page, the app should be in the Reminders page. Check for the page title if this is the case.

Run all the tests in iosAppUITests class by putting your cursor in the middle of its name and pressing Command-U.

Browse through the results in the Xcode console, or see the green checkmarks in the code gutter and the Test Navigator and rejoice!

Fig. 8.12 — Xcode UI Test Success
Fig. 8.12 — Xcode UI Test Success

Challenge

Here is a challenge for you to see if you’ve got the idea. The solution is inside the materials for this chapter.

Challenge: Writing Tests for RemindersRepository

Going one level deeper into the app’s architectural monument, it’s essential to have a bulletproof repository. After all, repositories are the backbone of the viewModels in Organize. Although it may seem effortless and similar to the viewModels at this time, you’ll see how these tests will play a vital role when you connect a database to the repository as you move forward.

With this explanation in mind, try to create a test suite for RemindersRepository.

Key Points

  • KMP will help you write less test code in the same way that it helped you write less business logic code.
  • You can write tests for your common code as well as for platform-specific code — all in Kotlin.
  • Declaring dependencies to a testing library for each platform is necessary. KMP will run your tests via the provided environment — such as JUnit on JVM.
  • Using expect/actual mechanisms in test codes is possible.
  • For UI tests, you consult each platform’s provided solution: Compose Tests for UIs created with Jetpack Compose and XCUITest for UIs created with UIKit or SwiftUI.

Where to Go From Here?

This chapter barely scratched the surface of testing. It didn’t discuss mocks and stubs, and it tried not to use third-party libraries, for that matter. There are a few libraries worth mentioning, though:

  • Kotest: A multiplatform Kotlin testing library with extended assertions and support for property testing. It can generate values for edge cases and random values.
  • Turbine: A small multiplatform library geared toward testing Koltin Flows. This chapter didn’t talk about Coroutines and Flows. However, if you ever wanted to test those, take a look at Turbine as kotlinx-coroutines-test library doesn’t support Kotlin/Native yet.
  • MockK: This is the most famous library for mocking in Kotlin. Although there’s a multiplatform version available, it lacks support for iOS.

If you are eager to learn more about testing in general, there are great resources out there, such as screencasts and articles, as well as these two books from kodeco.com:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.