Last Updated: Nov 8, 2024

How can you ensure visual consistency in your Jetpack Compose app without manual testing?

In modern Android development, ensuring the visual correctness of user interfaces is critical. Screenshot testing allows developers to automatically capture, compare, and verify the appearance of their app's UI. With the introduction of Compose Preview Screenshot Testing in Jetpack Compose, testing UI elements has become even more efficient and accessible. This tool allows you to take advantage of Compose's preview functionality to create pixel-perfect screenshots directly in your development environment.

In this codelab, you'll learn how to set up Compose Preview Screenshot Testing, create and capture screenshots of composables, and incorporate screenshot tests into your development workflow. By the end of this guide, you'll be equipped with the skills to ensure your app's UI remains visually consistent, reducing regressions and enhancing user experience without relying solely on manual testing.

What you'll build

In this codelab, you will:

  1. Set Up the Environment:
  1. Explore the JetLagged Project:
  1. Setup Jetpack Compose Screenshot Testing library
  1. Write Screenshot Tests:
  1. Generate reference images
  1. Run and Analyze Tests:

By the end of this codelab, participants will have practical experience writing and running screenshot tests for a real-world Jetpack Compose app, using a JetLagged project to ensure their app's UI remains consistent across updates and configurations.

What you'll learn

What you'll need


Helpful Readings

Before starting this Codelab, here are some articles to get you up to speed with Jetpack Compose Previews. This knowledge is useful for creating and manipulating previews for screenshot testing using Compose Preview Screenshot Testing

Setup Android Studio

You will be using Android Studio for this CodeLab. You can download the latest stable version for your computer. Once downloaded, please go through the setup wizard to ensure that the Java, SDK and Tools are correctly set up.

Download Android Studio

Get the code

I've put everything you need for this project into a Git repo. To get started, you'll need to grab the code and open it in Android Studio.

Clone the repository:

Using Android Studio or your favorite git client to use git.

Open Repository

Alternative: Download code without git features

If you want to just get the code instead, download the zip file below

Download ZIP

Open the project in Android Studio and let it sync. Once synced, your window should look like this.

Take some time to explore the project. In particular, have a look at various composables and previews.

Run the app on an emulator or a physical device and understand various UI elements.

Feel free to create new previews or setup data for any screenshot tests that you might want to create later.

The app contains 7 days with of sleep data at app/src/main/kotlin/me/kartikarora/jetlagged/data/FakeSleepData.kt

If you run into issues with Previews not rendering in Android Studio, in particular with an NoSuchElementException, there's an issue with Compose Preview tooling.

This can be fixed by generating a lot of fake data for this repository. By default, 7 days worth of data is provided, however you can generate 90 days worth of data.

In the terminal, run kotlinc -script scripts/generateRandomSleepData.main.kts.

This script updates the FakeSleepData.kt class with randomly generated 90 days worth of data.

About time yeah?


Update version catalog

In this project, we are using version catalog to manage our dependencies. If you have never used this before, have a read of my article State of Dependency Management in Android Studio. This should get you up to speed with version catalogs in Android Studio.

In the project, you will find the catalog defined in libs.versions.toml file in the gradle directory

gradle/libs.versions.toml

[versions]
...
screenshot = "0.0.1-alpha07"

[plugins]
...
screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot"}

... represents the existing entries which we are not modifying

Now hit the sync button for Android Studio to fetch the new dependency

Apply the plugin

Next, we need to tell our app module about this new plugin. Open the build.gradle.kts file for the app module and add the following

app/build.gradle.kts

plugins {
    ...
    alias(libs.plugins.screenshot)
}

android {
    ...
    experimentalProperties["android.experimental.enableScreenshotTest"] = true
}

dependencies {
    ...
    screenshotTestImplementation(libs.androidx.compose.ui.tooling)
}

Enable the experimental flag

As a final step, we want to tell gradle that we are explicitly enabling this experimental feature. This is done via the gradle.properties file.

gradle.properties

...
# Enable screenshot tests
android.experimental.enableScreenshotTest=true

Sync the project one more time. If there are no complaints from Android Studio, we are good to proceed to the next step.

We will be setting up our previews for screenshot testing, and then create some reference images. These reference images will be the "truth". Every validation of UI will use these reference images as baseline.

To designate the composable previews you want to use for screenshot tests, place the previews in a test class. The test class file must be located in the screenshotTest source set i.e. app/src/screenshotTest/kotlin/me/kartikarora/jetlagged/PreviewsForTest.kt

You can add more composables and/or previews, including multi-previews, in this file or other files created in the same sourceset.

app/src/screenshotTest/kotlin/me/kartikarora/jetlagged/PreviewsForTest.kt

class PreviewsForTest {
    @Composable
    @CombinedPreview
    fun JetLaggedHomeScreenPreview(@PreviewParameter(JetLaggedHomeScreenPreviewProvider::class) uiState: JetLaggedScreenState) {
        JetLaggedHomeScreen(
            sleepGraphData = uiState.sleepGraphData,
            wellnessData = uiState.wellnessData,
            heartRateData = uiState.heartRateData
        )
    }

    @Composable
    @CombinedPreview
    fun JetLaggedHSleepScreenPreview(@PreviewParameter(JetLaggedHomeScreenPreviewProvider::class) uiState: JetLaggedScreenState) {
        JetLaggedSleepScreen(sleepGraphData = uiState.sleepGraphData)
    }

    @Composable
    @CombinedPreview
    fun JetLaggedHeaderPreview(@PreviewParameter(HeaderPreviewDataProvider::class) header: String) {
        JetLaggedHeader(headerText = header)
    }

    @Composable
    @CombinedPreview
    fun JetLaggedHeaderTabsPreview() {
        JetLaggedHeaderTabs(onTabSelected = {}, selectedTab = SleepTab.Month)
    }

    // Heart Rate
    @Composable
    @CombinedPreview
    fun HeartRateCardPreview() {
        HeartRateCard()
    }

    @Composable
    @CombinedPreview
    fun HeartRateGraphPreview() {
        HeartRateGraph(heartRateGraphData)
    }

    // Sleep
    @CombinedPreview
    @Composable
    fun SleepBarPreview() {
        SleepBar(sleepData = sleepData.sleepDayData.first())
    }

    @CombinedPreview
    @Composable
    fun SleepGraphCardPreview(
    ) {
        SleepGraphCard(sleepState = sleepData)
    }

    @CombinedPreview
    @Composable
    fun SleepGraphCardWithHeaderPreview(
    ) {
        SleepGraphCard(sleepState = sleepData, cardHeading = "JetLagged")
    }

    // UI
    @CombinedPreview
    @Composable
    fun FadingCirclePreview() {
        FadingCircleBackground(bubbleSize = 48.dp, color = Color.Magenta)
    }
}

class HeaderPreviewDataProvider : PreviewParameterProvider<String> {
    override val values: Sequence<String> = sequenceOf("JetLagged", "Sleep", "Home")
}

Feel free to add more previews and play around with previews until you are happy with the results.

Now that the previews are ready, let's set up our reference images. To do so, open terminal in Android Studio and run the following gradle task

Linux & MacOS : ./gradlew :app:updateDebugScreenshotTest

Windows: gradlew :app:updateDebugScreenshotTest

Once the task completes, you will be able to find the reference images at app/src/debug/screenshotTest/reference/app/src/debug/screenshotTest/reference

With the reference images now in place, you should be able to run the tests using the gradle tasks

Linux and macOS: ./gradlew :app:validateDebugScreenshotTest

Windows: gradlew :app:validateDebugScreenshotTest

The validation task creates an HTML report at app/build/reports/screenshotTest/preview/debug/index.html

Play around with the previews or the UIs and run the validation task again. This time the tests will fail and the test report will show an image diff highlighting the differences

It is only natural that the UI will change and you would need to update the reference images when this happens. Otherwise your UI test will be correct, but because references are outdated, the tests will fail every time.

To update your references, all you have to do is run the update task again

Linux & MacOS : ./gradlew :app:updateDebugScreenshotTest

Windows: gradlew :app:updateDebugScreenshotTest

This will create new reference images at app/src/debug/screenshotTest/reference/app/src/debug/screenshotTest/reference

In this CodeLab, we'll focus on running the validation step on every push. We'll be using Github Actions as our CI server.

We'll be writing some YAML for this and declare a workflow where a few things happen on every push.

.github/workflows/validate_previews.yml

name: Validate Previews

on:
  push:

jobs:
  validate:
    runs-on: ubuntu-latest
    permissions:
      checks: write
      pull-requests: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - uses: gradle/actions/wrapper-validation@v4

      - name: Setup JDK
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: 21.0.4

      - name: Make Gradle executable
        run: chmod +x ./gradlew

      - name: Validate UI using screenshot tests
        run: ./gradlew app:validateScreenshotTest

      - name: Upload test result
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: 'screenshot-test-result'
          path: 'app/build/reports/screenshotTest/preview/debug'

      - name: Publish Test Results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: |
            app/build/test-results/**/*.xml

What this workflow does is

You can extend this to run for every pull request as well, however I'll leave that as a take home task.