Nice and simple DSL for Compose Multiplatform UI tests in Kotlin
Kakao Compose Multiplatform wraps the Compose UI testing API (androidx.compose.ui.test) in a
page-object style DSL. The same ComposeScreen and KNode definitions run in commonTest on
Android and iOS, and keep working in classic Android instrumented tests.
- Readability
- Reusability
- Extensible DSL
- Interceptors
- One test code base for Android and iOS
| Module | Android | iOS (iosArm64, iosSimulatorArm64) |
|---|---|---|
compose-multiplatform (core DSL) |
✅ | ✅ |
compose-multiplatform-semantics |
✅ | ✅ |
compose-multiplatform-ui |
✅ | ➖ |
compose-multiplatform-test |
✅ | ➖ |
compose-multiplatform- core module:ComposeScreenpage objects,KNode, matchers, actions, assertions and interceptorscompose-multiplatform-semantics- optional set of semantics keys shared by the-uiand-testmodulescompose-multiplatform-ui- optional wrappers over Material components with the same signatures that publish those semanticscompose-multiplatform-test- optional typed nodes (KButtonNode,KTextNode, ...) that assert on the published semantics
iOS tests run on the iOS simulator and require macOS with Xcode. Only Apple-silicon simulators are
supported (iosSimulatorArm64); there is no iosX64 target.
A few features of the core module depend on JVM reflection, JUnit4 or Android resources. They are listed in the Android-only features section together with the iOS alternatives.
Kotlin Multiplatform, in the common test source set:
kotlin {
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
@OptIn(ExperimentalComposeLibrary::class)
implementation(compose.uiTest)
implementation("io.github.kakaocup:compose-multiplatform:<latest version>")
}
}
}Android instrumented tests:
dependencies {
androidTestImplementation("io.github.kakaocup:compose-multiplatform:<latest version>")
}dependencies {
androidTestImplementation 'io.github.kakaocup:compose-multiplatform:<latest version>'
}Optional modules. They are Android-only, so in a Kotlin Multiplatform project put them into Android
source sets (androidMain, androidDeviceTest) rather than commonMain / commonTest:
dependencies {
implementation("io.github.kakaocup:compose-multiplatform-ui:<latest version>")
androidTestImplementation("io.github.kakaocup:compose-multiplatform-test:<latest version>")
}Every push to master publishes -SNAPSHOT builds of all modules under the same coordinates. To use them add the
snapshot repository:
repositories {
maven("https://central.sonatype.com/repository/maven-snapshots/")
}Migrating from the Android-only Kakao Compose? See Migrating from Kakao Compose.
Declare a screen in commonTest:
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainActivityScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("MainScreen") }
) {
val myButton: KNode = child {
hasTestTag("myTestButton")
hasText("Button 1")
}
}Run it with runComposeUiTest. The ComposeUiTest receiver is a SemanticsNodeInteractionsProvider,
so it is passed straight into the screen:
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runComposeUiTest
import kotlin.test.Test
@OptIn(ExperimentalTestApi::class)
class SimpleTest {
@Test
fun simpleTest() = runComposeUiTest {
setContent { MainScreen() }
MainActivityScreen(this).apply {
myButton {
assertIsDisplayed()
assertTextContains("Button 1")
}
onNode {
hasTestTag("doesNotExist")
}.invoke {
assertDoesNotExist()
}
}
}
}On iOS the test runs on the simulator with :yourModule:iosSimulatorArm64Test.
On Android, commonTest code runs as a device test. Enable the device-test source set in the Android
target and give it a test runner and the activity that hosts runComposeUiTest:
kotlin {
androidLibrary {
withDeviceTestBuilder {
sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
sourceSets {
getByName("androidDeviceTest").dependencies {
implementation("androidx.test:runner:<version>")
implementation("androidx.compose.ui:ui-test-manifest:<version>")
}
}
}:yourModule:connectedAndroidDeviceTest installs and runs the tests on a connected device or emulator.
:yourModule:assembleAndroidDeviceTest only builds the test APK, which you can then run elsewhere, for example
through Firebase Test Lab as this repository does in CI. See the sample-kmp module for a complete,
working configuration.
class ExampleInstrumentedTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun simpleTest() {
onComposeScreen<MainActivityScreen>(composeTestRule) {
myButton {
assertIsDisplayed()
assertTextContains("Button 1")
}
}
}
}onComposeScreen<T> instantiates the screen through reflection, so it only works on Android. On iOS
instantiate the screen directly as shown in the multiplatform example above. Direct instantiation
works on Android too. See the sample module for the full Android test suite.
One of the main concepts of Compose is a presentation of UI according to a UI tree (UI hierarchy) with parent-children relationships. It means that a UI test library for Compose has to support parent-children relationships for nodes by default. Below is how Kakao Compose Multiplatform supports that approach.
Create your entity ComposeScreen where you will add the nodes involved in the interactions of the tests:
class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainActivityScreen>(
semanticsProvider = semanticsProvider
)ComposeScreen can represent the whole user interface or a portion of UI.
If you are using the Page Object pattern you can put the interactions of Kakao inside the Page Objects.
Usually a Screen in Compose is a UI element (node) too. That's why there is an additional option to declare ComposeScreen:
class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainActivityScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("MainScreen") }
)So, ComposeScreen is a BaseNode inheritor. As you've seen above, it is possible to describe
ComposeScreen without a viewBuilderAction when the screen is an abstraction without a clear connection to any node.
ComposeScreen contains KNodes, the Compose nodes where you want to do the interactions:
class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainActivityScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("MainScreen") }
) {
val myButton: KNode = child {
hasTestTag("myTestButton")
}
}myButton was declared as a child of MainActivityScreen.
It means that myButton will be matched using the matchers specified in the lambda explicitly and the parent matcher implicitly (MainActivityScreen).
Under the hood, the SemanticsMatcher of myButton is equal to hasTestTag("myTestButton") and hasAnyAncestor(MainActivityScreen.matcher).
Also, KNode can be declared as a child of another KNode:
class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainActivityScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("MainScreen") }
) {
val myButton: KNode = child {
hasTestTag("myTestButton")
}
val myButton2: KNode = myButton.child {
hasTestTag("myTestButton2")
}
}myButton2 will be matched with
hasTestTag("myTestButton2") and hasAnyAncestor(myButton.matcher) and hasAnyAncestor(MainActivityScreen.matcher).
We advise not to abuse nesting and to use only the chain "ComposeScreen" - "Element of ComposeScreen".
The last, KNode can be declared without the child function using only explicit matchers:
class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainActivityScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("MainScreen") }
) {
val myButton = KNode(semanticsProvider) {
hasTestTag("myTestButton")
}
}Every KNode is described by matchers. Some of the matchers provided by Kakao Compose Multiplatform:
hasTexthasTestTaghasContentDescriptionhasClickActionisEnabled/isNotEnabledisDialog/isPopuphasAnyAncestor/hasAnyDescendant/hasAnySiblinghasPositionto pick the n-th node when several nodes matchaddSemanticsMatcherto plug in any customSemanticsMatcher- and more
Matchers combine inside one builder:
val myButton: KNode = child {
hasTestTag("myTestButton")
hasText("Button 1")
}Once you have the ComposeScreen and the KNode defined, you only have to apply
the actions or assertions you know from Compose UI test:
MainActivityScreen(this).apply {
myButton {
assertIsDisplayed()
assertTextContains("Button 1")
performClick()
}
onNode {
hasTestTag("doesNotExist")
}.invoke {
assertDoesNotExist()
}
}Available on every node: assertions such as assertIsDisplayed, assertExists, assertDoesNotExist,
assertTextEquals, assertTextContains, assertIsEnabled, assertIsSelected, assertHasClickAction;
actions such as performClick, performTextInput, performTextClearance, performScrollTo,
performScrollToIndex, performTouchInput, performSemanticsAction.
If you need to add custom logic during the Kakao Compose -> Compose UI test call chain (for example, logging) or
if you need to completely change the events/commands that are being sent to Compose UI test
during runtime in some cases, you can use the intercepting mechanism. Interceptors are part of the
common module and work on every platform.
Interceptors are lambdas that you pass to a configuration DSL that will be invoked before calls happening from inside Kakao Compose.
You have the ability to provide interceptors at two different levels: the Kakao Compose runtime and any individual BaseNode instance.
Interceptors support the parent-children concept too: any BaseNode inherits the interceptors of its parents.
On each invocation of a Compose UI test function that can be intercepted, Kakao Compose will aggregate all available interceptors
for this particular call and invoke them in descending order: Active BaseNode interceptor -> Interceptor of the parent Active BaseNode -> ... -> Kakao Compose interceptor.
Each of the interceptors in the chain can break the chain call by setting isOverride to true during configuration.
In that case Kakao Compose will not only stop invoking the remaining interceptors in the chain, but will not perform the
Compose UI test call. It means that in such case, the responsibility to actually invoke the interaction lies on the shoulders
of the developer.
Here is an example of intercepting configurations, written for commonTest:
@OptIn(ExperimentalTestApi::class)
class SomeTest {
private val log = mutableListOf<String>()
@BeforeTest
fun setup() {
KakaoCompose { // Kakao Compose runtime
intercept {
onComposeInteraction {
onAll { log.add("ALL") }
onCheck { _, _ -> log.add("CHECK") }
onPerform { _, _ -> log.add("PERFORM") }
}
}
}
}
@AfterTest
fun tearDown() {
KakaoCompose.reset() // drop runtime-level interceptors between tests
}
@Test
fun test() = runComposeUiTest {
setContent { MyContent() }
MyScreen(this).apply {
intercept {
onCheck { interaction, assertion -> // Intercept check() call
println("$interaction is checking $assertion")
}
}
myView {
intercept { // Intercepting ComposeInteraction calls on this individual Node
onPerform(true) { interaction, action -> // Intercept perform() call and override the chain
// When performing actions on this node, the Kakao level interceptor will not be called
// and we have to manually invoke the interaction now.
println("$interaction is performing $action")
interaction.perform(action)
}
}
}
}
}
}The same intercept { } blocks work inside onComposeScreen<MyScreen>(composeTestRule) { ... } on Android.
For more detailed info please refer to the documentation.
By default Compose UI test matches against the merged semantics tree, which creates a lot of inconveniences with node matching. You can switch a single node to the unmerged tree inside its builder:
val myButton: KNode = child {
useUnmergedTree = true
hasTestTag("myTestButton")
}or override the default globally. The override is read when a node is built, so set it before the screens are created,
for example in a @BeforeTest method:
KakaoCompose.Override.useUnmergedTree = trueOn Android the same can be done per test class with KakaoComposeTestRule (see below).
Two groups of features do not work on iOS.
The first group is common API that compiles on every platform but throws UnsupportedOperationException
at runtime on iOS, because it needs JVM reflection or Android resources:
onComposeScreen<T>(): instantiate the screen directly instead,MyScreen(provider).apply { ... }.- Resource-id overloads of matchers such as
hasText(R.string.title)orhasTestTag(R.string.tag): iOS has no Android resource ids, so use string literals or Compose Resources there. - Custom
BaseNodesubclasses as children viachild<MyNode> { ... }: on Android the subclass needs a public(SemanticsNodeInteractionsProvider, NodeMatcher, BaseNode<*>)constructor, which is looked up by reflection. On iOSchildalways produces aKNode, so declare children asKNodeor construct the custom subclass directly.
The second group exists only in the Android source set of compose-multiplatform, so it can be referenced from
androidTest or androidDeviceTest but not from commonTest:
KakaoComposeTestRule(JUnit4 rule)KLazyListNode/KLazyListItemNodeandKListNode/KListItemNodewaitUntil { ... }on any node, built on the JUnit4ComposeTestRule. On iOS useComposeUiTest.waitUntildirectly.- Resource-id overloads of assertions such as
assertTextEquals(R.string.title)
Instantiates a screen by reflection so you don't repeat the class name:
onComposeScreen<MainActivityScreen>(composeTestRule) {
myButton { assertIsDisplayed() }
}Every Compose screen requires a semanticsProvider, which creates boilerplate like onComposeScreen<LazyListScreen>(composeTestRule) {.
You can provide the composeTestRule once through KakaoComposeTestRule and then use all screens without injection like onComposeScreen<LazyListScreen> {.
Both styles can be mixed: an explicitly injected provider overrides the one supplied via KakaoComposeTestRule.
The rule also overrides useUnmergedTree for the duration of the test and restores the previous values once the test
passes.
The no-argument onComposeScreen<T> { } creates the screen through its no-argument constructor, so such screens must
give semanticsProvider a default:
class GlobalSemanticScreen(semanticsProvider: SemanticsNodeInteractionsProvider? = null) :
ComposeScreen<GlobalSemanticScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("MainScreen") },
) {
val myText1: KNode = child {
hasTestTag("mySimpleText")
hasPosition(0)
}
}@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@get:Rule
val kakaoComposeTestRule = KakaoComposeTestRule(
semanticsProvider = composeTestRule,
useUnmergedTree = true
)
@Test
fun simpleTest() {
onComposeScreen<GlobalSemanticScreen> {
myText1 {
assertIsDisplayed()
assertTextContains("Simple text 1")
}
}
}You can find a full example in SimpleTestGlobalSemantic.
To test lazy lists such as LazyRow or LazyColumn add a KLazyListNode to your ComposeScreen:
val list = KLazyListNode(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("LazyList") },
itemTypeBuilder = {
itemType(::LazyListItemNode)
itemType(::LazyListHeaderNode)
},
positionMatcher = { position -> SemanticsMatcher.expectValue(LazyListItemPosition, position) }
)Inside the itemTypeBuilder function register KLazyListItemNode types to differentiate elements in the lazy list:
class LazyListItemNode(
semanticsNode: SemanticsNode,
semanticsProvider: SemanticsNodeInteractionsProvider,
) : KLazyListItemNode<LazyListItemNode>(semanticsNode, semanticsProvider)
class LazyListHeaderNode(
semanticsNode: SemanticsNode,
semanticsProvider: SemanticsNodeInteractionsProvider,
) : KLazyListItemNode<LazyListHeaderNode>(semanticsNode, semanticsProvider) {
val title: KNode = child {
hasTestTag("LazyListHeaderTitle")
}
}The element position might change during scrolling due to lazy list construction, that's why a positionMatcher is needed to determine the element position correctly. It can be achieved in different ways, for example through a TestTag:
LazyColumn(
Modifier
.fillMaxSize()
.testTag("LazyList")
) {
itemsIndexed(items) { index, item ->
when (item) {
is LazyListItem.Header -> ListItemHeader(item, Modifier.testTag("position=$index"))
is LazyListItem.Item -> ListItemCell(item, Modifier.testTag("position=$index"))
}
}
}And then check this position inside the positionMatcher lambda:
positionMatcher = { position -> hasTestTag("position=$position") }It is more convenient and less error prone to create a custom semantics property and modifier:
val LazyListItemPosition = SemanticsPropertyKey<Int>("LazyListItemPosition")
var SemanticsPropertyReceiver.lazyListItemPosition by LazyListItemPosition
fun Modifier.lazyListItemPosition(position: Int): Modifier {
return semantics { lazyListItemPosition = position }
}And check an item position with SemanticsMatcher:
positionMatcher = { position -> SemanticsMatcher.expectValue(LazyListItemPosition, position) }So the typical lazy list test may look like this:
@Test
@OptIn(ExperimentalTestApi::class)
fun lazyListTest() {
onComposeScreen<LazyListScreen>(composeTestRule) {
list {
firstChild<LazyListHeaderNode> {
title.assertTextEquals("Items from 1 to 10")
}
childWith<LazyListItemNode> {
hasText("Item 1")
} perform {
assertTextEquals("Item 1")
}
childAt<LazyListItemNode>(10) {
assertTextEquals("Item 10")
}
}
}
}Check the lazy list test example for more information.
Non-lazy lists are covered by KListNode / KListItemNode in the node.element.list package, see the
list tests.
The artifacts were renamed when the library moved to Compose Multiplatform, starting at version 2.0.0:
| Kakao Compose | Kakao Compose Multiplatform |
|---|---|
io.github.kakaocup:compose |
io.github.kakaocup:compose-multiplatform |
io.github.kakaocup:compose-semantics |
io.github.kakaocup:compose-multiplatform-semantics |
io.github.kakaocup:compose-ui |
io.github.kakaocup:compose-multiplatform-ui |
io.github.kakaocup:compose-test |
io.github.kakaocup:compose-multiplatform-test |
Package names did not change, so imports stay the same. Remove the old artifacts when switching: keeping both makes Gradle report a capability conflict.
Existing Android tests keep working without code changes. To share tests between Android and iOS, move your
screens and tests to commonTest, replace onComposeScreen<T>(rule) with direct instantiation, and run them
with runComposeUiTest.
Kakao Compose Multiplatform is an open source project, and depends on its users to improve it. We are more than happy to find you interested in taking the project forward.
Kindly refer to the Contribution Guidelines for detailed information.
Please refer to Code of Conduct document.
Kakao Compose Multiplatform is open source and available under the Apache License, Version 2.0.

