Kakao Compose

repository·master·Indexed 41 days ago

https://github.com/kakaocup/compose

A Kotlin DSL for Espresso Compose that provides a readable, reusable, and extensible way to write UI tests for Jetpack Compose applications. It features a hierarchical approach using ComposeScreen and KNode, specialized support for lists via KListNode and KLazyListNode, and a flexible interceptor system to customize or override Espresso calls.

Tokens
10.5K
Snippets
31
Records
39
Agent score
51%

What's inside Kakao Compose

  1. Overview of Kakao Compose modules

    master

    Kakao Compose is organized into four main modules designed to simplify UI testing in Android Compose. The modules are divided into a core module and several optional modules that build upon it:

    Core Module

    • compose: The central module. It provides a DSL wrapping for Espresso methods, ComposeScreen page objects, and KNode.

    Optional Modules

    • compose-semantics: Provides a set of default semantics keys used by the compose-ui and compose-test modules.
    • compose-ui: Contains wrappers for Compose base components. These wrappers maintain the same constructor signatures as the original components but automatically set all possible semantics from the compose-semantics module to facilitate testing.
    • compose-test: Provides specialized nodes for performing typed component validation.
  2. Understand the Kakao Compose module structure

    master

    Kakao Compose is organized into four primary modules. The core module provides the foundational DSL, while optional modules extend functionality for semantics, UI component wrappers, and specialized testing nodes.

    Dependency Hierarchy

    • compose-test depends on compose and compose-semantics.
    • compose-ui depends on compose-semantics.
    • compose and compose-semantics both depend on Android Compose.
    • compose depends on Espresso.

    Core Module: compose

    The foundational module providing a DSL wrapper for Espresso methods, ComposeScreen page objects, and support for KNode and lists. Version 1+ is designed for broad compatibility with version 0+.

  3. Understand the Page Object Pattern in Kakao Compose

    master

    The Page Object pattern is a design pattern used in UI testing to improve maintenance, readability, and reusability. It works by abstracting the UI structure and interactions into reusable components.

    Key Concepts

    • Page Object: A class representing a single page or component. It encapsulates elements (buttons, input fields) and actions (clicking, typing) related to that page, acting as an interface between test scripts and the UI.
    • Separation of Concerns: Test logic (assertions, flow) is separated from UI interaction logic. Tests focus on what to test, while Page Objects handle how to interact with the UI.
    • Reusability: Page Objects can be used across multiple tests. If the UI changes, you only update the Page Object, not every individual test.
    • Readability: Tests use high-level methods provided by Page Objects, making them more expressive.
  4. Intercept Espresso calls

    master

    Interceptors allow you to inject custom logic (like logging) or override Espresso calls. You can set interceptors at the global KakaoCompose runtime level or on individual BaseNode instances.

    Interceptors are executed in descending order: Active BaseNode interceptor -> Parent BaseNode interceptor -> ... -> Kakao-Compose interceptor.

    If an interceptor sets isOverride = true, the remaining interceptors are skipped and the actual Espresso call is not performed, giving the developer full control over the execution.

    @Before
    fun setup() {
        KakaoCompose {
            intercept {
                onComposeInteraction {
                    onAll { list.add("ALL") }
                    onCheck { _, _ -> list.add("CHECK") }
                    onPerform { _, _ -> list.add("PERFORM") }
                }
            }
        }
    }
    
    @Test
    fun testWithNodeInterceptor() {
        onComposeScreen<MyScreen> {
            myView {
                intercept {
                    onPerform(true) { interaction, action -> 
                        // isOverride = true
                        Log.d("KAKAO_NODE", "Intercepted: $action")
                        interaction.perform(action) // Manually calling Espresso
                    }
                }
            }
        }
    }
  5. How ComposeScreen and KNode work together

    master

    Kakao Compose uses a hierarchical approach to represent the Jetpack Compose UI tree.

    1. ComposeScreen: Represents a UI entity (a whole screen or a portion of it). It can be an abstraction or a specific Node by providing a viewBuilderAction (e.g., using hasTestTag).
    2. KNode: Represents specific interactive elements within a screen.

    Nodes can be defined as children of a ComposeScreen or as children of other KNodes. When a node is defined as a child, its SemanticMatcher is automatically composed of its own matchers plus the matchers of its parent(s), ensuring the test targets the correct element within the hierarchy.

    class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
        ComposeScreen<MainActivityScreen>(
            semanticsProvider = semanticsProvider,
            viewBuilderAction = { hasTestTag("MainScreen") }
        ) {
    
        // myButton is a child of MainActivityScreen
        val myButton: KNode = child {
            hasTestTag("myTestButton")
        }
    
        // myButton2 is a child of myButton
        val myButton2: KNode = myButton.child {
            hasTestTag("myTestButton2")
        }
    }
  6. Use `compose-semantics` for custom design systems

    master

    The compose-semantics module is a lightweight collection of default semantic keys used by compose-ui and compose-test to determine view states.

    When to use it: If you are building a custom design system and want to avoid a transitive dependency on the full compose-ui module, you can add compose-semantics explicitly and implement your own mappings based on the patterns found in the compose-ui source code.

    implementation("io.github.kakaocup:compose-semantics:1.0.0")
  7. Add Coroutines and Espresso dependencies for async testing

    master

    To implement IdlingResource with Coroutines, ensure your build.gradle includes the following dependencies:

    dependencies {
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
    
        androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
        androidTestImplementation("androidx.test:runner:1.5.2")
        androidTestImplementation("androidx.test:rules:1.5.2")
    }
  8. Synchronize UI tests with Coroutines using IdlingResource

    master

    When using Kotlin Coroutines in Android apps, you can synchronize Espresso UI tests with asynchronous tasks by implementing a custom IdlingResource that monitors a CoroutineDispatcher. This ensures Espresso waits for background tasks (like network requests) to complete before performing assertions.

    Implementation Steps:

    1. Create a CoroutineIdlingResource: An implementation of IdlingResource that uses an AtomicInteger to track active coroutines.
    2. Wrap the Dispatcher: Create an IdlingDispatcher that wraps a standard CoroutineDispatcher, calling increment() when a task is dispatched and decrement() when it finishes.
    3. Inject the Dispatcher: Use the IdlingDispatcher within your application code (e.g., in a ViewModel) instead of default dispatchers like Dispatchers.IO during tests.
    4. Register in Tests: Use Espresso.registerIdlingResources() in your @Before method and Espresso.unregisterIdlingResources() in your @After method.
    // 1. Create the IdlingResource
    class CoroutineIdlingResource : IdlingResource {
        private val counter = AtomicInteger(0)
        private var resourceCallback: IdlingResource.ResourceCallback? = null
    
        override fun getName(): String = CoroutineIdlingResource::class.java.name
        override fun isIdleNow(): Boolean = counter.get() == 0
        override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
            this.resourceCallback = callback
        }
    
        fun increment() = counter.getAndIncrement()
        fun decrement() {
            if (counter.decrementAndGet() == 0) {
                resourceCallback?.onTransitionToIdle()
            }
        }
    }
    
    // 2. Wrap the Dispatcher
    class IdlingDispatcher(private val idlingResource: CoroutineIdlingResource) : CoroutineDispatcher() {
        private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
        override fun dispatch(context: kotlin.coroutines.CoroutineContext, block: Runnable) {
            idlingResource.increment()
            dispatcher.dispatch(context, Runnable {
                try { block.run() } finally { idlingResource.decrement() }
            })
        }
    }
    
    // 3. Use in ViewModel and Register in Test
    @Test
    fun testFetchData() {
        // Espresso will wait until the coroutine finishes because of the registered IdlingResource
        onView(withId(R.id.fetchDataButton)).perform(click())
        onView(withId(R.id.resultTextView)).check(matches(withText("Data Loaded")))
    }
  9. Implement a Page Object (ComposeScreen)

    master

    To create a Page Object, define a class that extends ComposeScreen. You must provide a semanticsProvider and a viewBuilderAction to locate the root of the screen (typically using hasTestTag). Inside the class, define elements as KNode properties using the child { ... } builder.

    Important: Avoid Content Locators Do not use locators based on view content (e.g., hasText("Button 1")) as they are brittle. If the text changes, the test might fail or match the wrong element. Instead, use hasTestTag for stable identification.

    class MainActivityScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
      ComposeScreen<MainActivityScreen>(
          semanticsProvider = semanticsProvider,
          viewBuilderAction = { hasTestTag("MainScreen") }) 
       {
      val myText1: KNode = child {
          hasTestTag("mySimpleText")
      }
    
      val clickCounter: KNode = child {
          hasTestTag("clickCounter")
      }
    }
  10. Setup project dependencies for Kakao Compose

    master

    To use Kakao Compose in your Android project, add the following dependency to your build.gradle or build.gradle.kts file. Use the latest version available on the KakaoCup/compose GitHub repository.

    Note that this is an androidTestImplementation dependency, intended for use in Android instrumentation tests.

    dependencies {
        androidTestImplementation("io.github.kakaocup:compose:<latest version>")
    }