Compose Multiplatform UI Tests

Marko Novakovic
4 min readFeb 6, 2024
Photo by Robert Lukeman on Unsplash

Compose Multiplatform is a great initiative from JetBrains to make it possible to use Jetpack Compose to build iOS, desktop and web apps.

It came a long way but was lacking one (probably :D) important feature. UI tests. You could of course write pure Android Composed UI tests but that’s obviously not a solution since it is Compose Multiplatform. Until now it wasn’t possible to write and run UI tests on all supported platforms.

With its 1.6.0 release, which is in beta at the time of writing this article, it brings the ability to write multiplatform UI tests although it’s still experimental. Now we can test our UIs on different platform and be sure that it looks and behaves as expected, without doing it manually of course.

It requires a setup and there a some nuances when it comes to individual platforms. There are differences between Compose Multiplatform and Jetpack Compose UI tests in both setup and how we write them so let’s see how we can write UI tests for our Composed Multiplatform layouts.

Setup

Setup steps assume that you already have Compose Multiplatform set up and that module that contains Compose code is called shared, if your module is named differently just change name in ./gradlew commands bellow.

General setup

  1. First things first, we have to use a 1.6.0 release. Either beta or dev release, both will work. You can find all releases here so use the latest one available.
  2. Now we will do our Gradle setup. We need to modify commonTest source set. It should look like this:
// If you're using older source sets setup:
kotlin {
sourceSets {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))

@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.uiTest)
}
}
}
}

// If you're using new source sets setup:
kotlin {
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.uiTest)
}
}
}

As you can see we need Kotlin Test depencency alongside new compose.uiTest.
If you don’t already have shared/src/commonTest/kotlin directory go ahead and create it.

Setup with JUnit APIs for Desktop

Compose Multiplatform doesn’t use JUnit’s TestRunner and other APIs so if you want to use JUnit specific APIs you can do that only for Desktop target.

// If you're using older source sets setup:
kotlin {
sourceSets {
val desktopTest by getting {
dependencies {
implementation(compose.desktop.uiTestJUnit4)
implementation(compose.desktop.currentOs)
}
}
}
}

// If you're using new source sets setup:
kotlin {
sourceSets {
desktopTest.dependencies {
implementation(compose.desktop.uiTestJUnit4)
implementation(compose.desktop.currentOs)
}
}
}

In this case your tests will be inside shared/src/desktopTest/kotlin.

Writing tests

Let’s take a look at some Compose UI test.

@file:OptIn(ExperimentalTestApi::class)

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kotlin.test.Test

@Composable
fun Screen() {
var count by remember { mutableStateOf(0) }

Column {
Text(text = "Count")
Text(text = count.toString())
Button(onClick = { count++ }) {
Text("Button")
}
}
}

class UiTests {

@Test
fun `initial screen state`() = runComposeUiTest {
setContent {
Screen()
}

onNodeWithText("Count").assertIsDisplayed()
onNodeWithText("0").assertIsDisplayed()
onNodeWithText("Button").assertIsDisplayed()
}

@Test
fun `increment counter`() = runComposeUiTest {
setContent {
Screen()
}

onNodeWithText("0").assertIsDisplayed()
onNodeWithText("Button").performClick()
onNodeWithText("1").assertIsDisplayed()
}
}

If you are familiar with Jetpack Compose tests you can see the slight difference. Compose Multiplatform doesn’t use JUnit test rule @get:Rule but uses runComposeUiTest function instead, similar to runTest for testing suspend functions.

You can run tests on iOS either by pressing play icon or by running following command ./gradlew :shared:iosSimulatorArm64Test.

If you try to run tests on Android you’ll see that it doesn’t work. We need additional setup in order to run our tests on Android.

Run UI tests in Android emulator

We need to add following to build.gradle.kts androidTarget.

kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
instrumentedTestVariant {
sourceSetTree.set(KotlinSourceSetTree.test)

dependencies {
implementation("androidx.compose.ui:ui-test-junit4-android:version")
debugImplementation("androidx.compose.ui:ui-test-manifest:version")
}
}
}
}

Notice that now we are using androidx.compose and not multiplatform one.

We also need to set Android test runner.

android {
...
defaultConfig {
...
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
...
}
...
}

Gradle sync is required and we are ready.

Tests in Android emulator are run with ./gradlew :shared:connectedAndroidTest and as command says we need to have emulator already running.

Desktop UI tests

Desktop uses JUnit APIs and it changes how we write tests. It’s similar to native Jetpack Compose tests.

@file:OptIn(ExperimentalTestApi::class)

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test

@Composable
fun Screen() {
var count by remember { mutableStateOf(0) }

Column {
Text(text = "Count")
Text(text = count.toString())
Button(onClick = { count++ }) {
Text("Button")
}
}
}

class UiTests {
@get:Rule
val rule = createComposeRule()

@Test
fun `initial screen state`() {
rule.setContent {
Screen()
}

rule.onNodeWithText("Count").assertIsDisplayed()
rule.onNodeWithText("0").assertIsDisplayed()
rule.onNodeWithText("Button").assertIsDisplayed()
}

@Test
fun `increment counter`() {
rule.setContent {
Screen()
}

rule.onNodeWithText("0").assertIsDisplayed()
rule.onNodeWithText("Button").performClick()
rule.onNodeWithText("1").assertIsDisplayed()
}
}

You can see that only difference is the existance of @get:Rule val rule = createComposeRule() and calling setContent and interactions on it.

Tests can be ran with following command: ./gradlew :shared:desktopTest.

UI tests are, or at least should be, important as they ensure that our layouts look and behave as expected and in the same time reducing manual testing that needs to be done.

I hope this will help you and enable you to write tests or you awesome Compose Multiplatform UI. Until next time… Take care.

--

--