Android UI testing utils
A set of TestRules, ActivityScenarios and utils to facilitate UI & screenshot testing under
certain configurations, independent of the UI testing libraries you are using.
For screenshot testing, it supports Jetpack Compose, android Views (e.g. custom Views,
ViewHolders, etc.), Activities and Fragments, as well as Robolectric
Currently, with this library you can easily change the following configurations in your UI
tests:
- Locale (also Pseudolocales en_XA & ar_XB)
- App Locale (i.e. per-app language preference)
- System Locale
- Font size
- Orientation
- Custom themes
- Dark mode /Day-Night mode
- Display size
You can find out why verifying our design under such configurations is important in this blog post:
π¨ Design a pixel perfect Android app
For examples of usage of this library in combination with Shot and Dropshots, check the following
repo:
πΈ Android screenshot testing playground
In the near future, there are plans to also support, among others:
- Dynamic colors (via TestRule)
- Reduce snapshot testing flakiness
- Folding features
- Accessibility features
Sponsors
Thanks to Screenshotbot for their support!
By using Screenshotbot instead of the in-build record/verify modes provided by most screenshot
libraries, you'll give your colleages a better developer experience, since they will not be required
to manually record screenshots after every run, instead getting notifications on their Pull
Requests.
Table of Contents
Integration
Add jitpack to your root build.gradle
file:
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
then in your build.gradle
compileSdkVersion 33
dependencies {
androidTestImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:utils:2.0.0-beta03') {
// if necessary, add this to avoid compose version clashes
exclude group: 'androidx.compose.ui'
}
// add this if excluding 'androidx.compose.ui' due to compose version clashes
androidTestImplementation "androidx.compose.ui:ui-test-junit4:your_compose_version"
}
In-App Locale
AndroidUiTestingUtils also
supports per-app language preferences
. In order to change the In-App Locale, you need to use the InAppLocaleTestRule
. For that it is
necessary to add the following dependency in your build.gradle
androidTestImplementation 'androidx.appcompat:appcompat:1.6.0-alpha04' // or higher version!
Use this rule to test Activities with in-app Locales that differ from the System Locale.
System Locale
To change the System Locale via SystemLocaleTestRule, you also need to add the following permission
to your androidTest/manifest
.
For multi-module apps, do this in the app module.
<!-- Required to change the Locale via SystemLocaleTestRule (e.g. for snapshot testing Activities) -->
<uses-permission android:name="android.permission.CHANGE_CONFIGURATION"
tools:ignore="ProtectedPermissions" />
Robolectric screenshot tests (BETA)
Robolectric supports screenshot testing
via Robolectric Native graphics (RNG)
since 4.10.
Since AndroidUiTestingUtils:2.0.0-beta02
, you can configure your robolectric screenshot tests
similar to how you'd do it with on-device tests.
For that, add the following dependencies in your build.gradle
:
testImplementation 'com.github.sergio-sastre.AndroidUiTestingUtils:utils:2.0.0-beta03'
testImplementation 'com.github.sergio-sastre.AndroidUiTestingUtils:robolectric:2.0.0-beta03'
You can find some examples in this section as well as executable screenshot tests in the repo Android screenshot testing playground to try it on your own!
Cross-library screenshot tests (BETA)
Since AndroidUiTestingUtils:2.0.0-beta01
, there is support for running the same screenshot test
for your Composables (support for Android Views coming in beta04) with different libraries,
without
rewriting.
Currently, that's only possible with the following screenshot testing libraries 1:
-
First of all, configure all the screenshot testing libraries you want your tests to support, as if you'd write them with those specific libraries. Visit their respective Github pages for more info.
It's recommended to configure max 1 on-device (i.e. Shot or Dropshots) and max 1 JVM library (i.e. Paparazzi) to avoid misbehaviours when running their corresponding plugin gradle tasks. If necessary, it's possible to configure more though2. -
After that, include the following dependencies in the
build.gradle
of the module including the tests. Additionally, enable robolectric native graphics if using Roborazzi.
android {
testOptions {
...
unitTests {
...
all {
// NOTE: Only necessary if adding Roborazzi
systemProperty 'robolectric.graphicsMode', 'NATIVE'
}
}
}
}
dependencies {
debugImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:utils:2.0.0-beta03')
// NOTE: From here down, add only those for the libraries you're planning to use
// For Shot support
debugImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:shot:2.0.0-beta03')
// For Dropshots support
debugImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:dropshots:2.0.0-beta03')
// For Paparazzi support
debugImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:sharedtest-paparazzi:2.0.0-beta03')
testImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:paparazzi:2.0.0-beta03')
// For Roborazzi support
debugImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:sharedtest-roborazzi:2.0.0-beta03')
testImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:robolectric:2.0.0-beta03')
testImplementation('com.github.sergio-sastre.AndroidUiTestingUtils:roborazzi:2.0.0-beta03')
}
- To enable shared tests (i.e same test running either on the JVM or on a device/emulator), you
have 2 options:
- Create and write your tests in a share test module as described here or...
- Add this in the
build.gradle
of the module where you'll write shared tests. Then write your screenshot tests undersrc/sharedTest
.
android {
...
sourceSets {
test {
java.srcDir 'src/sharedTest/java'
}
androidTest {
java.srcDir 'src/sharedTest/java'
}
}
}
- Create the corresponding
SharedScreenshotTestRule
, for instance:
class CrossLibraryScreenshotTestRule(
override val config: ScreenshotConfig,
) : SharedScreenshotTestRule(config) {
override fun getJvmScreenshotTestRule(config: ScreenshotConfig): ScreenshotTestRule {
return paparazziScreenshotTestRule // or roborazziScreenshotTestRule
}
override fun getInstrumentedScreenshotTestRule(config: ScreenshotConfig): ScreenshotTestRule {
return dropshotsScreenshotTestRule // or shotScreenshotTestRule
}
}
- Finally, write your tests with the CrossLibraryScreenshotTestRule. For an example, see this section.
1 Support for
Facebook screenshot-tests-for-android, ndpt android-testify and QuickBird Studios snappy is on the roadmap.
2 You'll likely configure your screenshot tests to run with 1 on-device (i.e. either Shot or Dropshots) and 1 JVM library (i.e. Paparazzi). In that case, this is enough.
But if you need to run your tests with many on-device/JVM libraries i.e. Shot locally but Dropshots in the CI, you'll need some extra configuration to decide which library runs them. You can find an example of how to achieve it via a custom project gradle property passed via command line e.g. -PscreenshotLibrary=shot
. In that case, check these links for advice on how to configure the gradle file and the SharedScreenshotTestRule
:
And the corresponding executable examples:
Usage
Screenshot testing examples
The examples use pedrovgs/Shot. It also works with any other on-device screenshot testing library, like Facebook screenshot-tests-for-android, Dropbox Dropshots or with a custom screenshot testing solution.
You can find more complete examples with Shot, Dropshots & Roborazzi 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 rule =
activityScenarioForActivityRule<MyActivity>(
config = ActivityConfigItem(
orientation = Orientation.LANDSCAPE,
uiMode = UiMode.NIGHT,
fontSize = FontSize.HUGE,
systemLocale = "en",
displaySize = DisplaySize.LARGEST,
)
)
@Test
fun snapActivityTest() {
compareScreenshot(
activity = rule.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. Currently, this is the only means to set
- A TimeOut for the FontSize and DisplaySize TestRules
- A InAppLocaleTestRule for per-app language preferences
Apart from that, this would be equivalent:
// Sets the Locale of the app under test only, i.e. the per-app language preference feature
@get:Rule
val inAppLocale = InAppLocaleTestRule("ar")
// Sets the Locale of the Android system
@get:Rule
val systemLocale = SystemLocaleTestRule("en")
@get:Rule
val fontSize = FontSizeTestRule(FontSize.HUGE).withTimeOut(inMillis = 15_000) // default is 10_000
@get:Rule
val displaySize = DisplaySizeTestRule(DisplaySize.LARGEST).withTimeOut(inMillis = 15_000)
@get:Rule
val uiMode = UiModeTestRule(UiMode.NIGHT)
@Test
fun snapActivityTest() {
// Custom themes are not supported
// 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()
}
Warning
If using any TestRule with Ndtp android-testify, uselaunchActivity = false
for them to take effect:@get:Rule val activityTestRule = ScreenshotRule(CoffeeDrinksComposeActivity::class.java, launchActivity = false)
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()
}
If the View under test contains system Locale dependent code,
like NumberFormat.getInstance(Locale.getDefault())
, the Locale formatting you've set
via ActivityScenarioConfigurator.ForView().setLocale("my_locale")
will not work. That's because
NumberFormat is using the Locale of the Android system, and not that of the Activity we've
configured. Beware of using instrumenation.targetContext
in your tests when using getString() for
the very same reason: use Activity's context instead.
To solve that issue, you can do one of
the following:
- Use
NumberFormat.getInstance(anyViewInsideActivity.context.locales[0])
in your production code. - Use
SystemLocaleTestRule("my_locale")
in your tests instead ofActivityScenarioConfigurator.ForView().setLocale("my_locale")
.
Jetpack Compose
The simplest way is to use the ActivityScenarioForComposableRule, to avoid the need for:
- calling createEmptyComposeRule()
- closing the ActivityScenario.
@get:Rule
val rule = ActivityScenarioForComposableRule(
config = ComposableConfigItem(
fontSize = FontSize.SMALL,
locale = "de",
uiMode = UiMode.DAY,
displaySize = DisplaySize.LARGE,
orientation = Orientation.PORTRAIT,
),
backgroundColor = TRANSPARENT,
)
@Test
fun snapComposableTest() {
rule.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()
}
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",
)
If the Composable under test contains system Locale dependent code,
like NumberFormat.getInstance(Locale.getDefault())
, the Locale formatting you've set
via ActivityScenarioConfigurator.ForComposable().setLocale("my_locale")
will not work. That's
because NumberFormat is using the Locale of the Android system, and not that of the Activity we've
configured, which is applied to the LocaleContext of our Composables.
To solve that issue, you
can do one of the following:
- Use
NumberFormat.getInstance(LocaleContext.current.locales[0])
in your production code. - Use
SystemLocaleTestRule("my_locale")
in your tests instead ofActivityScenarioConfigurator.ForComposable().setLocale("my_locale")
.
Fragment
The simplest way is to use the FragmentScenarioConfiguratorRule
@get:Rule
val rule = 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 = rule.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 fragmentScenario =
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 = fragmentScenario.waitForFragment(),
name = "your_unique_screenshot_name",
)
fragmentScenario.close()
}
Bitmap
Most screenshot testing libraries use Canvas
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.
Fortunately, such libraries let you pass the bitmap (i.e.the screenshot) as argument in their
record/verify methods.
In doing so, we can draw the views with elevation1 to a bitmap with PixelCopy
.
AndroidUiTestingUtils provides methods to easily generate bitmaps from the Activities/Fragments/ViewHolders/Views/Dialogs/Composables:
drawToBitmap(config = Bitmap.Config.ARGB_8888)
-> usesCanvas
under the hooddrawToBitmapWithElevation(config = Bitmap.Config.ARGB_8888)
-> usesPixelCopy
under the hood
Differences between both might be specially noticeable in API 31:
Note
If usingPixelCopy
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.
Canvas | PixelCopy |
---|---|
e.g. long ScrollViews |
β Cannot render elements beyond the screen, resizing if that's the case |
1 Robolectric 4.10 or lower cannot render shadows or elevation with RNG, as stated in this issue 2 Elevation can be manifested in many ways: a UI layer on top of another or a shadow in a CardView.
And using PixelCopy
in your screenshot tests is as simple as this (example with Shot):
// 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",
)
Robolectric (BETA)
AndroidUiTesting includes some special ActivityScenarioConfigurators
and FragmentScenarioConfigurators
that are additionally safe-thread,
what allows unit tests in parallel without unexpected behaviours.
It supports Jetpack Compose*, android Views (e.g. custom Views, ViewHolders, etc.), Activities and Fragments.
Check out some examples below. It uses Roborazzi as screenshot testing library.
Activity (RNG)
Here with Junit4 test rule
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapActivityTest {
@get:Rule
val robolectricActivityScenarioForActivityRule =
robolectricActivityScenarioForActivityRule(
config = ActivityConfigItem(
systemLocale = "en",
uiMode = UiMode.NIGHT,
theme = R.style.Custom_Theme,
orientation = Orientation.PORTRAIT,
fontSize = FontSize.NORMAL,
displaySize = DisplaySize.NORMAL,
),
deviceScreen = DeviceScreen.Phone.PIXEL_4A,
)
@Config(sdk = [30]) // Do not use qualifiers if using `DeviceScreen` in the Rule
@Test
fun snapActivity() {
robolectricActivityScenarioForActivityRule
.rootView
.captureRoboImage("path/MyActivity.png")
}
}
or without Junit4 test rules
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapActivityTest {
@Config(sdk = [30]) // Do not use qualifiers if using `setDeviceScreen()
@Test
fun snapActivity() {
val activityScenario =
RobolectricActivityScenarioConfigurator.ForActivity()
.setDeviceScreen(DeviceScreen.Phone.PIXEL_4A)
.setSystemLocale("en")
.setUiMode(UiMode.NIGHT)
.setOrientation(Orientation.PORTRAIT)
.setFontSize(FontSize.NORMAL)
.setDisplaySize(DisplaySize.NORMAL)
.launch(MyActivity::class.java)
activityScenario
.rootView
.captureRoboImage("path/MyActivity.png")
activityScenario.close()
}
}
Android View (RNG)
Here with Junit4 test rule
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapViewHolderTest {
@get:Rule
val robolectricActivityScenarioForViewRule =
RobolectricActivityScenarioForViewRule(
config = ViewConfigItem(
locale = "en_XA",
uiMode = UiMode.NIGHT,
theme = R.style.Custom_Theme,
orientation = Orientation.PORTRAIT,
fontSize = FontSize.NORMAL,
displaySize = DisplaySize.NORMAL,
),
deviceScreen = DeviceScreen.Phone.PIXEL_4A,
backgroundColor = TRANSPARENT,
)
@Config(sdk = [30]) // Do not use qualifiers if using `DeviceScreen` in the Rule
@Test
fun snapViewHolder() {
val activity = robolectricActivityScenarioForViewRule.activity
val layout =
robolectricActivityScenarioForViewRule.inflateAndWaitForIdle(R.layout.memorise_row)
val viewHolder = waitForMeasuredViewHolder {
MemoriseViewHolder(
container = layout,
itemEventListener = null,
animationDelay = 0L,
).apply {
bind(generateMemoriseItem(rightAligned = false, activity = activity))
}
}
viewHolder
.itemView
.captureRoboImage("path/MemoriseViewHolder.png")
}
}
or without Junit4 test rules
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapViewHolderTest {
@Config(sdk = [30]) // Do not use qualifiers if using `setDeviceScreen()
@Test
fun snapViewHolder() {
val activityScenario =
RobolectricActivityScenarioConfigurator.ForView()
.setDeviceScreen(DeviceScreen.Phone.PIXEL_4A)
.setLocale("en_XA")
.setUiMode(UiMode.NIGHT)
.setTheme(R.style.Custom_Theme)
.setOrientation(Orientation.PORTRAIT)
.setFontSize(FontSize.NORMAL)
.setDisplaySize(DisplaySize.NORMAL)
.launchConfiguredActivity(TRANSPARENT)
val activity = activityScenario.waitForActivity()
val layout = activity.inflateAndWaitForIdle(R.layout.memorise_row)
val viewHolder = waitForMeasuredViewHolder {
MemoriseViewHolder(
container = layout,
itemEventListener = null,
animationDelay = 0L,
).apply {
bind(generateMemoriseItem(rightAligned = false, activity = activity))
}
}
viewHolder
.itemView
.captureRoboImage("path/MemoriseViewHolder.png")
activityScenario.close()
}
}
Jetpack Compose (RNG)
Here with RobolectricActivityScenarioForComposableRule
test rule
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapComposableTest {
@get:Rule
val activityScenarioForComposableRule =
RobolectricActivityScenarioForComposableRule(
config = ComposableConfigItem(
fontSize = FontSize.SMALL,
locale = "ar_XB",
uiMode = UiMode.DAY,
displaySize = DisplaySize.LARGE,
orientation = Orientation.PORTRAIT,
),
deviceScreen = DeviceScreen.Phone.PIXEL_4A,
backgroundColor = TRANSPARENT,
)
@Config(sdk = [30]) // Do not use qualifiers if using `setDeviceScreen()
@Test
fun snapComposable() {
activityScenarioForComposableRule
.activityScenario
.onActivity {
it.setContent {
AppTheme { // this theme must use isSystemInDarkTheme() internally
yourComposable()
}
}
}
activityScenarioForComposableRule
.composeRule
.onRoot()
.captureRoboImage("path/MyComposable.png")
}
}
or without RobolectricActivityScenarioForComposableRule
test rule
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapComposableTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
@Config(sdk = [30]) // Do not use qualifiers if using setDeviceScreen()
@Test
fun snapComposable() {
val activityScenario =
RobolectricActivityScenarioConfigurator.ForComposable()
.setDeviceScreen(DeviceScreen.Phone.PIXEL_4A)
.setFontSize(FontSize.SMALL)
.setLocale("ar_XB")
.setInitialOrientation(Orientation.PORTRAIT)
.setUiMode(UiMode.DAY)
.setDisplaySize(DisplaySize.LARGE)
.launchConfiguredActivity(TRANSPARENT)
.onActivity {
it.setContent {
AppTheme { // this theme must use isSystemInDarkTheme() internally
yourComposable()
}
}
}
activityScenario.waitForActivity()
composeTestRule
.onRoot()
.captureRoboImage("path/MyComposable.png")
activityScenario.close()
}
}
Fragment (RNG)
Here with Junit4 test rule
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapFragmentTest {
@get:Rule
val robolectricFragmentScenarioConfiguratorRule =
robolectricFragmentScenarioConfiguratorRule<MyFragment>(
fragmentArgs = bundleOf("arg_key" to "arg_value"),
config = FragmentConfigItem(
locale = "en_XA",
uiMode = UiMode.NIGHT,
theme = R.style.Custom_Theme,
orientation = Orientation.PORTRAIT,
fontSize = FontSize.NORMAL,
displaySize = DisplaySize.NORMAL,
),
deviceScreen = DeviceScreen.Phone.PIXEL_4A,
)
@Config(sdk = [30]) // Do not use qualifiers if using `DeviceScreen` in the Rule
@Test
fun snapFragment() {
robolectricFragmentScenarioConfiguratorRule
.fragment
.view!!
.captureRoboImage("path/MyFragment.png")
}
}
or without Junit4 test rules
@RunWith(RobolectricTestRunner::class) // or ParameterizedRobolectricTestRunner for parameterized test
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SnapFragmentTest {
@Config(sdk = [30]) // Do not use qualifiers if using `setDeviceScreen()
@Test
fun snapFragment() {
val fragmentScenario =
RobolectricFragmentScenarioConfigurator.ForFragment()
.setDeviceScreen(DeviceScreen.Phone.PIXEL_4A)
.setLocale("en_XA")
.setUiMode(UiMode.NIGHT)
.setTheme(R.style.Custom_Theme)
.setOrientation(Orientation.PORTRAIT)
.setFontSize(FontSize.NORMAL)
.setDisplaySize(DisplaySize.NORMAL)
.launchInContainer(
fragmentClass = MyFragment::class.java,
fragmentArgs = bundleOf("arg_key" to "arg_value"),
)
fragmentScenario
.waitForFragment()
.view!!
.captureRoboImage("path/MyActivity.png")
fragmentScenario.close()
}
}
Cross-library (BETA)
Currently, it only supports screnshot tests for composables. Support for Android Views is coming in AndroidUiTestingUtils:2.0.0-beta04
.
For cross-library screenshot tests, you need to follow the steps in this section. Once done, writing such tests is as easy as this:
- Define a ScreenshotTestRule with the default configuration, which can be overriden in your tests
fun defaultCrossLibraryScreenshotTestRule(config: ScreenshotConfig): ScreenshotTestRule =
CrossLibraryTestRule(config)
// Optional: Define special configurations for each library you're using
.configure(
ShotConfig(bitmapCaptureMethod = PixelCopy())
).configure(
DropshotsConfig(resultValidator = ThresholdValidator(0.15f))
).configure(
PaparazziConfig(deviceConfig = DeviceConfig.NEXUS_4)
).configure(
RoborazziConfig(
filePath = File(userTestFilePath()).path,
deviceScreen = DeviceScreen.Phone.NEXUS_4,
)
)
- Write a screenshot test
@RunWith(CrossLibraryScreenshotTestRunner::class)
class MyCrossLibraryScreenshotTest {
@get:Rule
val screenshotRule =
defaultCrossLibraryScreenshotTestRule(
config = ScreenshotConfig(
uiMode = UiMode.DAY,
orientation = Orientation.LANDSCAPE,
locale = "en",
fontScale = FontSize.NORMAL,
displaySize = DisplaySize.NORMAL,
),
)
// Optional: Override the default config for the desired libraries
.configure(
PaparazziConfig(deviceConfig = DeviceConfig.PIXEL_XL)
)
@Test
fun snapComposable() {
screenshotRule.snapshot(name = "your_unique_screenshot_name") {
MyComposable()
}
}
}
- or a parameterized screenshot test
import org.junit.runners.Parameterized // annotation @Parameterized.Parameters
@RunWith(ParameterizedCrossLibraryScreenshotTestRunner::class)
class MyParameterizedCrossLibraryScreenshotTest(
private val testItem: MyTestItemEnum,
) {
companion object {
@JvmStatic
@Parameterized.Parameters
fun testItemProvider(): Array<MyTestItemEnum> = MyTestItemEnum.values()
}
@get:Rule
val screenshotRule =
defaultCrossLibraryScreenshotTestRule(config = testItem.config)
// Optional: Override the default config for the desired libraries
.configure(
PaparazziConfig(deviceConfig = DeviceConfig.PIXEL_XL)
)
@Test
fun snapComposable() {
screenshotRule.snapshot(name = testItem.name) {
MyComposable()
}
}
}
Warning
You must define atestInstrumentationRunner
in build.gradle of typeandroidx.test.runner.AndroidJUnitRunner
for Parameterized Cross-Library screenshot tests to work. For instance,com.karumi.shot.ShotTestRunner
if using Shot.
Utils
Wait
-
waitForActivity
: Analog to the one defined in pedrovgs/Shot. It's also available in this library for compatibility with other screenshot testing frameworks like Facebook screenshot-tests-for-android . -
waitForFragment
: Analog to waitForActivity but for Fragment. -
activity.waitForComposeView
: Returns the root Composable in the activity as a ComposeView. You can call laterdrawToBitmap
ordrawToBitmapWithElevation
on it to screenshot test its corresponding bitmap. -
waitForMeasuredView/Dialog/ViewHolder(exactWidth, exactHeight)
: Inflates the layout in the main thread, sets its width and height to those given, and waits till the thread is idle, returning the inflated view. Comes in handy with libraries that do not support, to take a screenshot with a given width/height, like Dropshots.
Warning
PreferwaitForMeasuredView
overwaitForView
(which is discouraged), specially if using Dropshots:
Inflate or measure
activity.inflate(R.layout_of_your_view)
: Use it to inflate android Views with the activity's context configuration. In doing so, the configuration becomes effective in the view. It also adds the view to the Activity's root.activity.inflateAndWaitForIdle(R.layout_of_your_view)
: Like activity.inflate, but waits till the view is Idle to return it. Do not wrap it with waitForMeasuredView{} or it will throw an exception.MeasureViewHelpers
: Analog to theViewHelpers
defined in Facebook screenshot-tests-for-android . In most cases, you don't need to use it directly but viawaitForMeasuredView(exactWidth, exactHeight)
, which callsMeasuredViewHelpers
under the hood.
Reading on screenshot testing
- An introduction to snapshot testing on Android in 2021
πΈ - The secrets of effectively snapshot testing on Android
π - UI tests vs. snapshot tests on Android: which one should I write?
π€ - Design a pixel perfect Android app π¨
Standard UI testing
For standard UI testing, you can use the same approach as for snapshot testing Activities. The following TestRules and methods are provided:
// Sets the Locale of the app under test only, i.e. the per-app language preference feature
@get:Rule
val inAppLocale = InAppLocaleTestRule("en")
// Sets the Locale of the Android system
@get:Rule
val systemLocale = SystemLocaleTestRule("en")
@get:Rule
val fontSize = FontSizeTestRule(FontSize.HUGE).withTimeOut(inMillis = 15_000) // default is 10_000
@get:Rule
val displaySize = DisplaySizeTestRule(DisplaySize.LARGEST).withTimeOut(inMillis = 15_000)
@get:Rule
val uiMode = UiModeTestRule(UiMode.NIGHT)
activity.rotateTo(Orientation.LANDSCAPE)
Warning
When using DisplaySizeTestRule and FontSizeTesRule together in the same test, make sure your emulator has enough RAM and VM heap to avoid Exceptions when running the tests. The recommended configuration is the following:
- RAM: 4GB
- VM heap: 1GB
Code attributions
This library has been possible due to the work others have done previously. Most TestRules are based on code written by others:
- SystemLocaleTestRule -> Screengrab
- FontSizeTestRule -> Novoda/espresso-support
- UiModeTestRule -> AdevintaSpain/Barista
- Orientation change for activities -> Shopify/android-testify
- MeasureViewHelpers -> a copy of ViewHelpers from Facebook screenshot-tests-for-android
Contributing
- Create an issue in this repo
- Fork the repo Android screenshot testing playground
- In that repo, add an example and test where the bug is reproducible/ and showcasing the new feature.
- Once pushed, add a link to the PR in the issue created in this repo and add @sergio-sastre as a reviewer.
- Once reviewed and approved, create an issue in this repo.
- Fork this repo and add the approved code from the other repo to this one (no example or test needed). Add @sergio-sastre as a reviewer.
- Once approved, I will merge the code in both repos, and you will be added as a contributor to Android UI testing utils as well as Android screenshot testing playground .
I'll try to make the process easier in the future if I see many issues/feature requests incoming :)
Android UI testing utils
logo modified from one by Freepik - Flaticon