Instrumentation tests usage

These examples use pedrovgs/Shot. It also works with any other on-device screenshot testing library that supports ActivityScenarios, like

For Android-Testify, which does not support ActivityScenario but the deprecated ActivityTestRule, have a look at the Activity-Testify usage section.

You can find more complete examples with Shot, Dropshots, Roborazzi and Android-Testify in the Android screenshot testing playground repo.

Activity

The simplest way is to use the ActivityScenarioForActivityRule, to avoid the need for closing the ActivityScenario.

@get:Rule
val screenshotRule =
    activityScenarioForActivityRule<MyActivity>(
        config = ActivityConfigItem(
            orientation = Orientation.LANDSCAPE,
            uiMode = UiMode.NIGHT,
            fontSize = FontSize.LARGEST,
            systemLocale = "en",
            displaySize = DisplaySize.LARGEST,
        )
    )

@Test
fun snapActivityTest() {
    compareScreenshot(
        activity = screenshotRule.activity,
        name = "your_unique_screenshot_name",
    )
}

In case you don't want to/cannot use the rule, you can use ActivityScenarioConfigurator.ForActivity() directly in the test.

Apart from that, this would be equivalent

// Sets the Locale of the Android system
@get:Rule(order = 0)
val systemLocale = SystemLocaleTestRule("en")

@get:Rule(order = 1)
val fontSize = FontSizeTestRule(FontSizeScale.Value(1.75f))

@get:Rule(order = 2)
val displaySize = DisplaySizeTestRule(DisplaySize.LARGEST)

@get:Rule(order = 3)
val uiMode = UiModeTestRule(UiMode.NIGHT)

@Test
fun snapActivityTest() {
    // AppLocale, SystemLocale, FontSize & DisplaySize are only supported via TestRules for Activities
    val activityScenario = ActivityScenarioConfigurator.ForActivity()
        .setOrientation(Orientation.LANDSCAPE)
        .launch(MyActivity::class.java)

    val activity = activityScenario.waitForActivity()

    compareScreenshot(
        activity = activity,
        name = "your_unique_screenshot_name"
    )

    activityScenario.close()
}

Fragment

The simplest way is to use the fragmentScenarioConfiguratorRule

@get:Rule
val screenshotRule = fragmentScenarioConfiguratorRule<MyFragment>(
        fragmentArgs = bundleOf("arg_key" to "arg_value"),
        config = FragmentConfigItem(
            orientation = Orientation.LANDSCAPE,
            uiMode = UiMode.DAY,
            locale = "de",
            fontSize = FontSize.SMALL,
            displaySize = DisplaySize.SMALL,
            theme = R.style.Custom_Theme,
        ),
    )

@Test
fun snapFragment() {
    compareScreenshot(
        fragment = screenshotRule.fragment,
        name = "your_unique_screenshot_name",
    )
}

In case you don't want to/cannot use the rule, you can use the plain FragmentScenarioConfigurator. This would be its equivalent:

@Test
fun snapFragment() {
    val screenshotRule =
        FragmentScenarioConfigurator
            .setInitialOrientation(Orientation.LANDSCAPE)
            .setUiMode(UiMode.DAY)
            .setLocale("de")
            .setFontSize(FontSize.SMALL)
            .setDisplaySize(DisplaySize.LARGE)
            .setTheme(R.style.Custom_Theme)
            .launchInContainer<MyFragment>(
                fragmentArgs = bundleOf("arg_key" to "arg_value"),
            )

    compareScreenshot(
        fragment = screeenshotRule.waitForFragment(),
        name = "your_unique_screenshot_name",
    )

    screenshotRule.close()
}

Android View

The simplest way is to use the ActivityScenarioForViewRule, to avoid the need for closing the ActivityScenario.

@get:Rule
val rule =
    ActivityScenarioForViewRule(
        config = ViewConfigItem(
            fontSize = FontSize.NORMAL,
            locale = "en",
            orientation = Orientation.PORTRAIT,
            uiMode = UiMode.DAY,
            theme = R.style.Custom_Theme,
            displaySize = DisplaySize.SMALL,
        ),
        backgroundColor = TRANSPARENT,
    )

@Test
fun snapViewHolderTest() {
    // IMPORTANT: The rule inflates a layout inside the activity with its context to inherit the configuration 
    val layout = rule.inflateAndWaitForIdle(R.layout.your_view_holder_layout)

    // wait asynchronously for layout inflation 
    val viewHolder = waitForMeasuredViewHolder {
        YourViewHolder(layout).apply {
            // bind data to ViewHolder here
            ...
        }
    }

    compareScreenshot(
        holder = viewHolder,
        heightInPx = viewHolder.itemView.height,
        name = "your_unique_screenshot_name",
    )
}

In case you don't want to/cannot use the rule, you can use ActivityScenarioConfigurator.ForView(). This would be its equivalent:

// example for ViewHolder
@Test
fun snapViewHolderTest() {
    val activityScenario =
        ActivityScenarioConfigurator.ForView()
            .setFontSize(FontSize.NORMAL)
            .setLocale("en")
            .setInitialOrientation(Orientation.PORTRAIT)
            .setUiMode(UiMode.DAY)
            .setTheme(R.style.Custom_Theme)
            .setDisplaySize(DisplaySize.SMALL)
            .launchConfiguredActivity(TRANSPARENT)

    val activity = activityScenario.waitForActivity()

    // IMPORTANT: To inherit the configuration, inflate layout inside the activity with its context 
    val layout = activity.inflateAndWaitForIdle(R.layout.your_view_holder_layout)

    // wait asynchronously for layout inflation 
    val viewHolder = waitForMeasuredViewHolder {
        YourViewHolder(layout).apply {
            // bind data to ViewHolder here
            ...
        }
    }

    compareScreenshot(
        holder = viewHolder,
        heightInPx = viewHolder.itemView.height,
        name = "your_unique_screenshot_name",
    )

    activityScenario.close()
}

Jetpack Compose

The simplest way is to use the ActivityScenarioForComposableRule, to avoid the need for:

  1. calling createEmptyComposeRule()

  2. closing the ActivityScenario.

@get:Rule
val screenshotRule = ActivityScenarioForComposableRule(
        config = ComposableConfigItem(
            fontSize = FontSize.SMALL,
            locale = "de",
            uiMode = UiMode.DAY,
            displaySize = DisplaySize.LARGE,
            orientation = Orientation.PORTRAIT,
        ),
        backgroundColor = TRANSPARENT,
    )

@Test
fun snapComposableTest() {
    screenshotRule.activityScenario
        .onActivity {
            it.setContent {
                AppTheme { // this theme must use isSystemInDarkTheme() internally
                    yourComposable()
                }
            }
        }

    compareScreenshot(
        rule = rule.composeRule,
        name = "your_unique_screenshot_name",
    )
}

In case you don't want to/cannot use the rule, you can use ActivityScenarioConfigurator.ForComposable() together with createEmptyComposeRule(). This would be its equivalent:

// needs an EmptyComposeRule to be compatible with ActivityScenario
@get:Rule
val composeTestRule = createEmptyComposeRule()

@Test
fun snapComposableTest() {
    val activityScenario = ActivityScenarioConfigurator.ForComposable()
        .setFontSize(FontSize.SMALL)
        .setLocale("de")
        .setInitialOrientation(Orientation.PORTRAIT)
        .setUiMode(UiMode.DAY)
        .setDisplaySize(DisplaySize.LARGE)
        .launchConfiguredActivity(TRANSPARENT)
        .onActivity {
            it.setContent {
                AppTheme { // this theme must use isSystemInDarkTheme() internally
                    yourComposable()
                }
            }
        }

    activityScenario.waitForActivity()

    compareScreenshot(rule = composeTestRule, name = "your_unique_screenshot_name")

    activityScenario.close()
}

With Dropshots

If you are using a screenshot library that cannot take a ComposeTestRule as argument (e.g. Dropshots), you can still screenshot the Composable as follows:

// with ActivityScenarioForComposableRule
dropshots.assertSnapshot(
    view = activityScenarioForComposableRule.activity.waitForComposeView(),
    name = "your_unique_screenshot_name",
)

or

// with ActivityScenarioConfigurator.ForComposable()
val activityScenario =
    ActivityScenarioConfigurator.ForComposable()
    ...
    .launchConfiguredActivity()
    .onActivity {
        ...
    }

dropshots.assertSnapshot(
    view = activityScenario.waitForActivity().waitForComposeView(),
    name = "your_unique_screenshot_name",
)

Rendering elevation

Most screenshot testing libraries use Canvas under the hood with Bitmap.Config.ARGB_8888 as default for generating bitmaps (i.e. the screenshots) from the Activities/Fragments/ViewHolders/Views/Dialogs/Composables...

That's because Canvas is supported in all Android versions. Nevertheless, such bitmaps generated using Canvas have some limitations, e.g. UI elements are rendered without considering elevation (e.g. without shadows). So, how to render screenshots with elevation?

Elevation can be manifested in many ways: e.g. a UI layer on top of another or a shadow in a CardView.

Bitmap

Fortunately, most libraries let you pass a bitmap of the UI as an argument in their record/verify methods. In doing so, we can draw the views with elevation to a bitmap by usingPixelCopy.

Robolectric 4.10+ cannot render shadows or elevation with RNG even with PixelCopy, as stated in this issue

AndroidUiTestingUtils provides methods to easily generate bitmaps from the Activities/Fragments/ViewHolders/Views/Dialogs/Composables:

  1. drawToBitmap(config = Bitmap.Config.ARGB_8888) -> uses Canvas under the hood

  2. drawToBitmapWithElevation(config = Bitmap.Config.ARGB_8888) -> uses PixelCopy under the hood

and one extra to fully screenshot a scrollable Android View:

  1. drawFullScrollableToBitmap(config = Bitmap.Config.ARGB_8888) -> uses Canvas under the hood

Differences between Bitmaps generated via Canvas and Pixel Copy might be specially noticeable on API 31:

If using PixelCopy with ViewHolders/Views/Dialogs/Composables, consider launching the container Activity with transparent background for a more realistic screenshot of the UI component.

ActivityScenarioConfigurator.ForView() // or .ForComposable()
      ...
      .launchConfiguredActivity(backgroundColor = Color.TRANSPARENT)

or

ActivityScenarioForViewRule( // or ActivityScenarioForComposableRule()
      viewConfig = ...,
      backgroundColor = Color.TRANSPARENT,
)

Otherwise it uses the default Dark/Light Theme background colors (e.g. white and dark grey).

Using PixelCopy instead of Canvas comes with its own drawbacks though. In general, don't use PixelCopy to draw views that don't fit on the screen.

CanvasPixelCopy

✅ Can render elements beyond the screen, e.g. long ScrollViews

❌ Cannot render elements beyond the screen, resizing them to fit in the window

if that's the case

❌ Ignores elevation of UI elements

✅ Renders elevation of UI elements

And using PixelCopy in your screenshot tests is as simple as this:

// for UI Components like Activities/Fragments/ViewHolders/Views/Dialogs
compareScreenshot(
    bitmap = uiComponent.drawToBitmapWithElevation(),
    name = "your_unique_screenshot_name",
)
// for Composables
compareScreenshot(
    bitmap = activity.waitForComposableView().drawToBitmapWithElevation(),
    name = "your_unique_screenshot_name",
)

Last updated