Skip to content
Skillv1.0.0

kotlin-multiplatform-roborazzi

Complete UI layer testing for KMP: semantic test tags on composables, Compose UI interaction tests in commonTest (runComposeUiTest, onNodeWithTag, performClick, assertIsDisplayed) that run on every ta

by ronjunevaldoz(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from ronjunevaldoz/graphyn-editor (.claude/skills/kotlin-multiplatform-roborazzi/SKILL.md). Install upstream with npx skills add ronjunevaldoz/graphyn-editor --skill kotlin-multiplatform-roborazzi. Copyright stays with the author (Apache-2.0).

When to Use This Skill

Use when you need to:

  • Add semantic testTag identifiers to composables so tests can target specific nodes
  • Write Compose UI interaction tests (onNodeWithTag, performClick, assertIsDisplayed)
  • Capture screenshot golden images from @Preview composables on JVM
  • Detect visual regressions automatically in CI
  • Wire the full UI testing stack: test tags → interaction tests → screenshot tests

Trigger keywords: screenshot test, Roborazzi, golden image, visual regression, preview screenshot, UI test JVM, screenshot diff, CI visual test, testTag, test tag, compose test rule, onNodeWithTag, createComposeRule, interaction test, compose UI test, semantics node, visual accuracy, pixel-perfect, layout test, canvas test, arrangement test, layout regression, 100% accuracy, UI layout verification, canvas layout test, visual confirmation, test layout, test UI, test screen, UI testing, visual test, test this screen, test the layout, screenshot testing, visual regression testing, UI coverage, test composable, drag test, swipe test, performTouchInput, performMouseInput, test drag, test resizable panel, test scrollbar, boundsInRoot, layout stability test.

Freshness rule: Roborazzi is actively developed — the Gradle plugin API and the captureRoboImage API change between minor versions. Recheck the GitHub releases page before pinning a version. runComposeUiTest (org.jetbrains.compose.ui:ui-test) tracks the Compose Multiplatform version and is still @ExperimentalTestApi — recheck its signature before upgrading CMP.


Recommendation First

Default to this three-layer UI testing stack:

  1. Test tags on every interactive or assertable node — Modifier.testTag(FooTestTags.LOGIN_BUTTON)
  2. Interaction tests in commonTest with runComposeUiTest — verify behaviour (enabled/disabled, text shown, clicks fire). Required CI gate: jvmTest, no emulator needed. Per-platform matrix (androidDeviceTest, iosSimulatorArm64Test, wasmJsTest) is opt-in/nightly — see CI Integration below.
  3. Roborazzi screenshot tests in jvmTest — verify visual output (layout, color, loading/error/empty states). For feature Content screens, cover phone, tablet, and desktop sizes, and record both light and dark themes when the screen supports them.

Why:

  • Test tags make tests stable — onNodeWithTag doesn't break when copy changes
  • commonTest interaction tests catch real platform-specific bugs (text input, focus, gesture handling differ per target) that JVM-only tests structurally cannot
  • Roborazzi goldens catch unintentional visual regressions that logic tests miss
  • Roborazzi has no multiplatform equivalent — it's built directly on Robolectric's Android-framework shadow rendering, so screenshot tests stay JVM-only regardless of where interaction tests live
  • Keeping the full device/simulator matrix out of the required PR gate keeps CI fast; emulator/simulator boot time is real cost that shouldn't block every push

Gradle Setup

libs.versions.toml

[versions]
roborazzi = "1.64.0"

[libraries]
roborazzi             = { module = "io.github.takahirom.roborazzi:roborazzi",             version.ref = "roborazzi" }
roborazzi-compose     = { module = "io.github.takahirom.roborazzi:roborazzi-compose",     version.ref = "roborazzi" }
roborazzi-junit-rule  = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule",  version.ref = "roborazzi" }

[plugins]
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }

Convention plugin: GROUP_ID.feature.ui.gradle.kts

plugins {
    id("org.jetbrains.kotlin.multiplatform")
    id("com.android.kotlin.multiplatform.library")
    id("org.jetbrains.compose")
    id("org.jetbrains.kotlin.plugin.compose")
    id("io.github.takahirom.roborazzi")
}

roborazzi {
    outputDir = project.file("src/jvmTest/snapshots")
}

Feature :ui module build.gradle.kts

plugins {
    id("GROUP_ID.feature.ui")
}

kotlin {
    androidLibrary {
        namespace = "GROUP_ID.feature.FEATURE_NAME.ui"
    }

    sourceSets {
        commonTest.dependencies {
            implementation(compose.uiTest)              // runComposeUiTest — commonTest, multiplatform
            implementation(libs.kotlin.test)
        }
        jvmTest.dependencies {
            implementation(libs.roborazzi)               // Roborazzi is JVM/Robolectric-only
            implementation(libs.roborazzi.compose)
            implementation(libs.roborazzi.junit.rule)
        }
    }
}

compose.uiTest is inherited by every platform test source set (jvmTest, androidDeviceTest, iosSimulatorArm64Test, wasmJsTest) automatically — declare it once in commonTest, not per-target. Android instrumented tests additionally need a minimal AndroidManifest.xml under src/androidDeviceTest/; see the official Compose Multiplatform testing guide for the exact setup.


Step 1: Test Tags

Create a TestTags object per feature. Place it in commonMain so both production code and tests can reference the constants without string literals.

// :feature:auth:ui/src/commonMain/kotlin/GROUP_ID/feature/auth/ui/AuthTestTags.kt
package GROUP_ID.feature.auth.ui

object AuthTestTags {
    const val EMAIL_FIELD        = "auth:email_field"
    const val PASSWORD_FIELD     = "auth:password_field"
    const val LOGIN_BUTTON       = "auth:login_button"
    const val LOADING_INDICATOR  = "auth:loading_indicator"
    const val ERROR_MESSAGE      = "auth:error_message"
}

Naming convention: <feature>:<node> — the feature prefix avoids collisions when multiple features are on screen simultaneously (e.g. in a navigation test).

Apply tags in AuthContent:

AppTextField(
    value = state.email,
    onValueChange = { onIntent(AuthContract.Intent.EmailChanged(it)) },
    modifier = Modifier.testTag(AuthTestTags.EMAIL_FIELD),
)

AppTextField(
    value = state.password,
    onValueChange = { onIntent(AuthContract.Intent.PasswordChanged(it)) },
    modifier = Modifier.testTag(AuthTestTags.PASSWORD_FIELD),
    isPassword = true,
)

AppButton(
    onClick = { onIntent(AuthContract.Intent.LoginClicked) },
    enabled = !state.isLoading,
    modifier = Modifier
        .fillMaxWidth()
        .testTag(AuthTestTags.LOGIN_BUTTON),
)

if (state.isLoading) {
    AppSpinner(modifier = Modifier.testTag(AuthTestTags.LOADING_INDICATOR))
}

if (state.error != null) {
    AppText(
        text = state.error,
        modifier = Modifier.testTag(AuthTestTags.ERROR_MESSAGE),
    )
}

Tag what matters — tag interactive nodes (buttons, fields) and assertable output nodes (error banners, loading indicators). Don't tag decorative containers.


Step 2: Compose UI Interaction Tests (commonTest)

Interaction tests live in commonTest and run per-target via runComposeUiTest — the same test body executes under jvmTest (required CI gate), and optionally under androidDeviceTest, iosSimulatorArm64Test, wasmJsTest (opt-in/nightly matrix, see CI Integration). No JUnit4 @get:RulerunComposeUiTest takes a lambda with a ComposeUiTest receiver that exposes the same onNodeWithTag/performClick/assert API.

// :feature:auth:ui/src/commonTest/kotlin/GROUP_ID/feature/auth/ui/AuthContentInteractionTest.kt
package GROUP_ID.feature.auth.ui

import androidx.compose.ui.test.*
import GROUP_ID.core.designsystem.theme.AppTheme
import kotlin.test.Test

class AuthContentInteractionTest {

    @OptIn(ExperimentalTestApi::class)
    @Test
    fun loginButton_isDisabled_whenLoading() = runComposeUiTest {
        setContent {
            AppTheme {
                AuthContent(
                    state = AuthContract.State(isLoading = true),
                    onIntent = {},
                )
            }
        }
        onNodeWithTag(AuthTestTags.LOGIN_BUTTON).assertIsNotEnabled()
    }

    @OptIn(ExperimentalTestApi::class)
    @Test
    fun errorMessage_isDisplayed_whenErrorInState() = runComposeUiTest {
        setContent {
            AppTheme {
                AuthContent(
                    state = AuthContract.State(error = "Invalid credentials"),
                    onIntent = {},
                )
            }
        }
        onNodeWithTag(AuthTestTags.ERROR_MESSAGE)
            .assertIsDisplayed()
            .assertTextContains("Invalid credentials")
    }

    @OptIn(ExperimentalTestApi::class)
    @Test
    fun loadingIndicator_isDisplayed_whenLoading() = runComposeUiTest {
        setContent {
            AppTheme {
                AuthContent(
                    state = AuthContract.State(isLoading = true),
                    onIntent = {},
                )
            }
        }
        onNodeWithTag(AuthTestTags.LOADING_INDICATOR).assertIsDisplayed()
    }

    @OptIn(ExperimentalTestApi::class)
    @Test
    fun loginButton_firesIntent_whenClicked() = runComposeUiTest {
        val intents = mutableListOf<AuthContract.Intent>()
        setContent {
            AppTheme {
                AuthContent(
                    state = AuthContract.State(),
                    onIntent = { intents.add(it) },
                )
            }
        }
        onNodeWithTag(AuthTestTags.LOGIN_BUTTON).performClick()

        assert(intents.contains(AuthContract.Intent.LoginClicked))
    }

    @OptIn(ExperimentalTestApi::class)
    @Test
    fun emailField_updatesState_onTextInput() = runComposeUiTest {
        val intents = mutableListOf<AuthContract.Intent>()
        setContent {
            AppTheme {
                AuthContent(
                    state = AuthContract.State(),
                    onIntent = { intents.add(it) },
                )
            }
        }
        onNodeWithTag(AuthTestTags.EMAIL_FIELD).performTextInput("user@example.com")

        assert(
            intents.filterIsInstance<AuthContract.Intent.EmailChanged>()
                .any { it.value == "user@example.com" }
        )
    }
}

Key APIs (unchanged from JUnit4 — only the harness differs):

API Use
onNodeWithTag(tag) Target a node by semantic tag
assertIsDisplayed() Node is visible on screen
assertIsNotEnabled() Node is disabled
assertTextContains(text) Node contains the given text
performClick() Simulate a tap
performTextInput(text) Type into a text field
assertDoesNotExist() Node is not in the composition
onNodeWithTag(tag, useUnmergedTree = true) Target inside a merged semantics tree

Layout stability regression test (trigger position on toggle)

A screenshot golden alone won't reliably catch a trigger button shifting a few pixels when toggled (collapsible/accordion chevrons, expand/collapse triggers) — diff tools often tolerate small deltas. Assert the trigger's bounds directly instead:

@OptIn(ExperimentalTestApi::class)
@Test
fun collapsibleTrigger_positionUnchanged_whenToggled() = runComposeUiTest {
    setContent {
        AppTheme { Collapsible(/* ... */) }
    }
    val before = onNodeWithTag(CollapsibleTestTags.TRIGGER).fetchSemanticsNode().boundsInRoot
    onNodeWithTag(CollapsibleTestTags.TRIGGER).performClick()
    mainClock.advanceTimeBy(250)  // past the toggle animation's tween duration
    val after = onNodeWithTag(CollapsibleTestTags.TRIGGER).fetchSemanticsNode().boundsInRoot

    assertEquals(before, after)
}

This is the deterministic version of the two failure modes the kotlin-multiplatform-audit detectors toggle icon swap instead of rotation [MEDIUM] and bare conditional collapse [MEDIUM] catch statically — write this test alongside any collapsible/accordion trigger, not just for components that already broke once.

Drag interaction test (resizable panels, custom scrollbar thumbs)

runComposeUiTest doesn't have a Modifier.draggable-specific assertion — drive the gesture with performTouchInput { swipe(...) } (or performMouseInput { press(); moveTo(); release() } for Desktop-only pointer drags) and assert on the resulting state, not on intermediate frames:

@OptIn(ExperimentalTestApi::class)
@Test
fun resizablePanelGroup_drag_resizesStartPaneWithinBounds() = runComposeUiTest {
    setContent {
        AppTheme { AppResizablePanelGroup(start = { StartPane() }, end = { EndPane() }) }
    }
    val divider = onNodeWithTag(ResizablePanelTestTags.DIVIDER)
    val startBoundsBefore = onNodeWithTag(ResizablePanelTestTags.START_PANE)
        .fetchSemanticsNode().boundsInRoot

    divider.performTouchInput { swipe(start = center, end = center.copy(x = center.x + 100f)) }

    val startBoundsAfter = onNodeWithTag(ResizablePanelTestTags.START_PANE)
        .fetchSemanticsNode().boundsInRoot
    assertTrue(startBoundsAfter.width > startBoundsBefore.width)
}

Test the clamp explicitly, not just "dragging changes something" — swipe far past the divider's travel range and assert the pane width stops at minWeight/maxWeight rather than continuing to shrink/grow or overshooting past the container:

@OptIn(ExperimentalTestApi::class)
@Test
fun resizablePanelGroup_drag_clampsToMaxWeight() = runComposeUiTest {
    setContent {
        AppTheme {
            AppResizablePanelGroup(start = { StartPane() }, end = { EndPane() }, maxWeight = 0.85f)
        }
    }
    onNodeWithTag(ResizablePanelTestTags.DIVIDER)
        .performTouchInput { swipe(start = center, end = center.copy(x = center.x + 5000f)) }

    val rootWidth = onRoot().fetchSemanticsNode().boundsInRoot.width
    val startWidth = onNodeWithTag(ResizablePanelTestTags.START_PANE).fetchSemanticsNode().boundsInRoot.width
    assertTrue(startWidth <= rootWidth * 0.85f + 1f)  // +1f tolerance for rounding
}

The same performTouchInput { swipe(...) } pattern applies to a custom scrollbar thumb built with pointerInput — assert the underlying ScrollState.value (or LazyListState.firstVisibleItemIndex) changed after the drag, not the thumb's own pixel position, since the thumb's position is derived from scroll state, not the other way around.


Step 3: Roborazzi Screenshot Tests (jvmTest only)

Unlike interaction tests, screenshot tests stay in jvmTest — Roborazzi has no multiplatform equivalent.

// :feature:auth:ui/src/jvmTest/kotlin/GROUP_ID/feature/auth/ui/AuthContentScreenshotTest.kt
package GROUP_ID.feature.auth.ui

import com.github.takahirom.roborazzi.captureRoboImage
import GROUP_ID.core.designsystem.theme.AppTheme
import kotlin.test.Test

class AuthContentScreenshotTest {

    @Test
    fun authContent_default() {
        captureRoboImage("auth_content_default.png") {
            AppTheme {
                AuthContent(state = AuthContract.State(), onIntent = {})
            }
        }
    }

    @Test
    fun authContent_loading() {
        captureRoboImage("auth_content_loading.png") {
            AppTheme {
                AuthContent(state = AuthContract.State(isLoading = true), onIntent = {})
            }
        }
    }

    @Test
    fun authContent_error() {
        captureRoboImage("auth_content_error.png") {
            AppTheme {
                AuthContent(state = AuthContract.State(error = "Session expired"), onIntent = {})
            }
        }
    }

    @Test
    fun authContent_dark() {
        captureRoboImage("auth_content_dark.png") {
            AppTheme(darkTheme = true) {
                AuthContent(state = AuthContract.State(), onIntent = {})
            }
        }
    }
}

Each call writes a PNG to src/jvmTest/snapshots/.

Required minimum per screen:

  • _light + _dark variant for the default state — always, no exceptions
  • _light + _dark variant for each meaningful variant (loading, error, empty)
  • If adaptive layout is in use: Compact + Expanded × light + dark = minimum 4 captures

A test with only a light capture is a reviewer blocker ([THEME]). A color that passes visual review in light mode may be invisible or low-contrast in dark mode.

Avoid redundant captures (don't capture every error message — capture the error state shape once, light and dark).


Step 3b: Bounds Sidecar (exact position/size regression — no vision needed)

A pixel diff on the golden PNG tells you that something changed, not what. Asking an agent to read the diff image and estimate "did this move 8px or 12px?" from a screenshot is unreliable — vision models aren't precise at exact pixel numbers. The fix isn't a smarter image comparison; it's to stop deriving position/size from pixels at all.

onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot (already used in the layout stability regression pattern above) gives exact position and size straight from the semantics tree. Write it to a small JSON file next to the golden PNG, and a position/size regression becomes a normal git diff line on a committed text file — exact numbers, zero noise for nodes that didn't move, no image analysis required:

// :core:testing/src/jvmTest/kotlin/GROUP_ID/core/testing/BoundsSnapshot.kt
package GROUP_ID.core.testing

import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.onNodeWithTag
import java.io.File

/**
 * Writes exact position/size for [tags] to a JSON sidecar next to the Roborazzi golden PNG
 * of the same name — a position/size regression then shows up as a plain git diff on the
 * sidecar instead of something a reviewer has to eyeball from two screenshots.
 * @receiver Must be called from inside `runDesktopComposeUiTest`, after `setContent` —
 * bounds are read from the live semantics tree at the point of the call.
 */
@OptIn(ExperimentalTestApi::class)
fun SemanticsNodeInteractionsProvider.captureBoundsSnapshot(
    fileName: String,
    vararg tags: String,
    outputDir: File = File("src/jvmTest/snapshots"),
) {
    val bounds = tags.associateWith { tag -> onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot }
    val json = bounds.entries.sortedBy { it.key }.joinToString(",\n", prefix = "{\n", postfix = "\n}") { (tag, r) ->
        "  \"$tag\": {\"left\": ${r.left}, \"top\": ${r.top}, \"width\": ${r.width}, \"height\": ${r.height}}"
    }
    outputDir.mkdirs()
    File(outputDir, fileName).writeText(json + "\n")
}

Call it in the same test that records the golden, using the real multiplatform-JVM captureRoboImage entry point (onRoot().captureRoboImage(...) inside runDesktopComposeUiTest, not the plain content-lambda form) so both come from the same composition pass:

import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.runDesktopComposeUiTest
import com.github.takahirom.roborazzi.captureRoboImage
import GROUP_ID.core.testing.captureBoundsSnapshot
import kotlin.test.Test

class AuthContentScreenshotTest {

    @OptIn(ExperimentalTestApi::class)
    @Test
    fun authContent_default() = runDesktopComposeUiTest {
        setContent {
            AppTheme {
                AuthContent(state = AuthContract.State(), onIntent = {})
            }
        }
        onRoot().captureRoboImage("auth_content_default.png")
        captureBoundsSnapshot(
            "auth_content_default.bounds.json",
            AuthTestTags.EMAIL_FIELD, AuthTestTags.PASSWORD_FIELD, AuthTestTags.LOGIN_BUTTON,
        )
    }
}

This writes auth_content_default.bounds.json next to auth_content_default.png in src/jvmTest/snapshots/ — commit both. On a PR, git diff on the .bounds.json file shows the exact change ("top": 120.0"top": 128.0), with nothing printed for tags that didn't move.

What this does not replace: border width and corner radius are not screenshot problems — they're literal values in ButtonStyles.kt/CardStyles.kt (RoundedCornerShape(appTheme.shapes.md), borderWidth(1.dp)). A regression there is already a normal code diff on the Style file, partly caught statically by scan_design_violations.py. Don't add pixel-based border/corner-radius detection — it would be strictly less precise than the value that's already sitting in source.


Recording and Verifying Goldens

# Record (first run — writes golden PNGs to snapshots/)
./gradlew :feature:auth:ui:jvmTest -PrecordRoborazzi

# Verify (diff against committed goldens)
./gradlew :feature:auth:ui:jvmTest

# Verify all :ui modules at once
./gradlew jvmTest

Commit the snapshots/ directory to git — this includes any .bounds.json sidecars written by captureBoundsSnapshot, since they live alongside the PNGs in the same directory. PRs that change UI produce image diffs and exact position/size diffs in the PR review — reviewers see before/after without running tests locally.


CI Integration

jvmTest is the required gate — it runs both the commonTest interaction tests (via their JVM actualization) and the jvmTest-only Roborazzi screenshots in one fast step, no emulator or simulator involved.

# .github/workflows/ci.yml
test-screenshot:
  name: UI + Screenshot Tests (JVM)
  runs-on: ubuntu-latest
  needs: lint
  steps:
    - uses: actions/checkout@v4

    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'zulu'

    - name: Setup Gradle
      uses: gradle/actions/setup-gradle@v4
      with:
        cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}

    - name: Run UI + screenshot tests
      run: ./gradlew jvmTest

    - name: Upload screenshot diffs on failure
      if: failure()
      uses: actions/upload-artifact@v4
      with:
        name: screenshot-diffs
        path: '**/src/jvmTest/snapshots/**/*_compare.png'
        retention-days: 7

Opt-in / nightly matrix — runs the same commonTest interaction tests on real platform targets to catch platform-specific rendering/input bugs. Don't add this to the required per-PR gate; emulator and simulator boot time is real CI cost.

# .github/workflows/nightly-ui-matrix.yml
on:
  schedule:
    - cron: '0 4 * * *'   # nightly
  workflow_dispatch: {}    # or manually, per PR label

jobs:
  ios-simulator:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - run: ./gradlew iosSimulatorArm64Test

  android-instrumented:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          script: ./gradlew connectedAndroidTest

  wasm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./gradlew wasmJsTest

Related Skills

  • kotlin-multiplatform-preview-driven-development — the @Preview workflow that feeds directly into Roborazzi
  • kotlin-multiplatform-presenter-module — Screen/Content split that makes Content injectable with fixed state
  • kotlin-multiplatform-unit-testing — Roborazzi covers :ui; use runTest + Turbine for :presenter and :domain
  • kotlin-multiplatform-ci-github-actions — where the CI screenshot job is wired
  • kotlin-multiplatform-design-system — owns the Style/token source that border-width and corner-radius regressions are diffed against directly, instead of re-deriving them from a screenshot

Visual Design Audit

After recording new golden images, run /kmm-audit-screenshots to verify the goldens themselves are design-system-compliant — not just pixel-stable. The audit uses Claude vision and checks:

Category What is checked
Color tokens No raw Color(0xFF…) visible; backgrounds use semantic surface colors
Dark mode parity Dark variant has dark background; text is light-on-dark, not invisible
AppScaffold structure TopAppBar present; title not duplicated in content body; back button in nav slot
Spacing Content has outer padding; list items have consistent internal padding
Typography Body readable; headings distinct; text truncates with ellipsis
Contrast Text on colored backgrounds is readable; disabled states are visually distinct

Position and size regressions are checked separately, and exactly — /kmm-audit-screenshots diffs any .bounds.json sidecar (see "Step 3b: Bounds Sidecar" above) before touching vision at all, since a sidecar diff is an exact number and vision is an estimate.

Running the audit:

# After recording new goldens — pass the project root, not the snapshots path
./gradlew recordRoborazziJvm
/kmm-audit-screenshots .

/kmm-audit-screenshots resolves the output directory dynamically by reading roborazzi { outputDir = ... } from build.gradle.kts. If outputDir is not set, it falls back to src/jvmTest/snapshots/ (jvmTest target) or src/test/snapshots/ (Android target). Never hardcode the path — it varies by project configuration.

The audit is also wired into /kmm-verify (Step 5) — it runs automatically after jvmTest if new or modified PNGs are present.

Findings map to reviewer blockers: FAIL-level → [THEME] or [LAYOUT]; WARNING-level → non-blocking.


Common Anti-Patterns

  • using Playwright, computer-use tooling, adb screencap, xcrun simctl io, or Robot.createScreenCapture for UI screenshots — use captureRoboImage on JVM instead; manually launching and driving the app requires a running device/emulator, produces non-reproducible results, and produces nothing committable. This applies to "verify this UI change" / "check the screen" requests too, not just when explicitly writing a screenshot test — reach for runComposeUiTest/captureRoboImage before reaching for a live app or a computer-use tool
  • using onNodeWithText("Sign in") instead of onNodeWithTag — breaks when copy changes; always use tags
  • tagging the Screen composable (with a real ViewModel) — inject fixed state into Content instead
  • not committing golden images — CI has nothing to diff against; diffs only work with committed goldens
  • running Roborazzi on Android instead of JVM — slower, needs emulator; use jvmTest unless Android-specific resources are required
  • one test class per state instead of one class per component — excessive boilerplate; group all states in one test class
  • forgetting to record new goldens after a planned UI change — run -PrecordRoborazzi and commit the updated images
  • covering only one device size in a feature screenshot test — preview coverage should span phone, tablet, and desktop
  • putting test tag constants as bare string literals in the test — define them in object FooTestTags in commonMain
  • using assertTextContains for copy that will be localized — use assertIsDisplayed() on the tagged node instead
  • trying to move Roborazzi screenshot tests to commonTest — Roborazzi has no multiplatform equivalent; it depends directly on Robolectric's Android-framework shadow rendering, so it stays JVM-only regardless of where interaction tests live
  • adding iosSimulatorArm64Test/connectedAndroidTest to the required per-PR CI gate — emulator/simulator boot time is expensive; keep the full device matrix opt-in or nightly and jvmTest as the required fast gate
  • writing new interaction tests with createComposeRule + JUnit4 @get:Rule in jvmTest — use runComposeUiTest in commonTest instead so the same test body can run per-target
  • asking Claude vision to estimate an exact position/size delta from two screenshots — vision isn't precise at exact pixel numbers; capture .bounds.json via captureBoundsSnapshot instead and diff the text file
  • writing a pixel-based border-width or corner-radius detector — those values already exist exactly in the Style source (ButtonStyles.kt/CardStyles.kt); a regression there is a normal code diff, not something to re-derive from an image

If a screenshot test fails after a Compose upgrade, re-record goldens — font rendering shifts between versions.


Output Style

When asked about UI testing, test tags, or visual regression for KMP, respond in this order:

  1. TestTag object setup with naming convention
  2. Modifier.testTag() applied to the composable
  3. Interaction test with createComposeRule + onNodeWithTag
  4. Roborazzi screenshot test for the same component
  5. record/verify commands and CI job

Changelog

Date Change
2026-07-10 Added "Step 3b: Bounds Sidecar" — captureBoundsSnapshot() writes exact fetchSemanticsNode().boundsInRoot position/size to a .bounds.json file next to each golden PNG, so a position/size regression is a plain git diff on committed text instead of something an agent has to estimate from a pixel diff image. Proven with a standalone JSON-diff test (exact delta surfaced, zero noise for unchanged nodes) before writing this into the skill. Verified the real multiplatform-JVM captureRoboImage entry point (onRoot().captureRoboImage(...) inside runDesktopComposeUiTest) against Roborazzi's own sample-compose-desktop-jvm test, since it differs from the plain content-lambda form. Wired into /kmm-record-design-baselines (sidecars ride along in the existing snapshots/ copy step) and /kmm-audit-screenshots (new Step 2b checks sidecar diffs before falling through to vision). Explicitly out of scope: pixel-based border-width/corner-radius detection — those values already exist exactly in Style source, so a regression there is a normal code diff. 2 new anti-patterns, 1 new Related Skills cross-reference.
2026-07-08 Added a "Drag interaction test" pattern — performTouchInput { swipe(...) } / performMouseInput { press(); moveTo(); release() } for resizable panel dividers and custom scrollbar thumbs, asserting resulting state (pane width, clamp bounds, scroll offset) rather than intermediate frames.
2026-07-08 Added a "Layout stability regression test" pattern — asserting boundsInRoot() on a trigger before/after toggle (via mainClock.advanceTimeBy) to deterministically catch a collapsible/accordion trigger shifting position on toggle. Cross-links the new kotlin-multiplatform-audit detectors toggle icon swap instead of rotation and bare conditional collapse.
2026-07-07 Moved Compose UI interaction tests from jvmTest/createComposeRule+JUnit4 to commonTest/runComposeUiTest, so the same test body runs per-target (JVM, Android instrumented, iOS simulator, Wasm). Roborazzi screenshot tests stay jvmTest-only (no multiplatform equivalent — depends on Robolectric shadow rendering). Added an opt-in/nightly CI matrix job alongside the required jvmTest gate, updated Gradle setup (compose.uiTest in commonTest.dependencies), and 3 new anti-patterns.
2026-06-20 Initial release.

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/ronjunevaldoz-graphyn-editor-kotlin-multiplatform-roborazzi/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

ronjunevaldoz-graphyn-editor-kotlin-multiplatform-roborazzi.ocm.jsonjson
{
  "ocm": "1",
  "id": "ronjunevaldoz-graphyn-editor-kotlin-multiplatform-roborazzi",
  "kind": "skill",
  "name": "kotlin-multiplatform-roborazzi",
  "description": "Complete UI layer testing for KMP: semantic test tags on composables, Compose UI interaction tests in commonTest (runComposeUiTest, onNodeWithTag, performClick, assertIsDisplayed) that run on every target — JVM, Android instrumented, iOS simulator, Wasm — and Roborazzi screenshot tests that capture @Preview composables on JVM/Desktop for visual regression detection. Covers the full stack from testTag conventions to CI golden image diffs. Replaces kotlin-multiplatform-testing-robot for UI regression testing.",
  "publisher": "ronjunevaldoz",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "coding"
    ],
    "tags": [
      "skill-md",
      "roborazzi",
      "screenshot-test",
      "golden-image",
      "preview",
      "jvm-screenshot",
      "visual-regression",
      "ci-diff",
      "kmp",
      "kotlin-multiplatform"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "Complete UI layer testing for KMP: semantic test tags on composables, Compose UI interaction tests in commonTest (runComposeUiTest, onNodeWithTag, performClick, assertIsDisplayed) that run on every target — JVM, Android instrumented, iOS simulator, Wasm — and Roborazzi screenshot tests that capture @Preview composables on JVM/Desktop for visual regression detection. Covers the full stack from testTag conventions to CI golden image diffs. Replaces kotlin-multiplatform-testing-robot for UI regression testing."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/ronjunevaldoz/graphyn-editor",
      "path": ".claude/skills/kotlin-multiplatform-roborazzi/SKILL.md",
      "ref": "ad3e6d1f6468c488faf2f1d404da81bae12d9c85",
      "url": "https://github.com/ronjunevaldoz/graphyn-editor/blob/ad3e6d1f6468c488faf2f1d404da81bae12d9c85/.claude/skills/kotlin-multiplatform-roborazzi/SKILL.md",
      "key": "ronjunevaldoz/graphyn-editor/.claude/skills/kotlin-multiplatform-roborazzi/SKILL.md"
    },
    "license": "Apache-2.0"
  },
  "instructions": "## When to Use This Skill\n\nUse when you need to:\n- Add semantic `testTag` identifiers to composables so tests can target specific nodes\n- Write Compose UI interaction tests (`onNodeWithTag`, `performClick`, `assertIsDisplayed`)\n- Capture screenshot golden images from `@Preview` composables on JVM\n- Detect visual regressions automatically in CI\n- Wire the full UI testing stack: test tags → interaction tests → screenshot tests\n\n**Trigger keywords:** screenshot test, Roborazzi, golden image, visual regression, preview screenshot,\nUI test JVM, screenshot diff, CI visual test, testTag, test tag, co",
  "cost": {
    "context_tokens": 7718
  }
}

Fetch it by URL: GET /api/v1/registry/ronjunevaldoz-graphyn-editor-kotlin-multiplatform-roborazzi/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.