Photo by Jakob Owens on Unsplash
Write Once, Test Everywhere: Cross-Library Screenshot Testing with AndroidUiTestingUtils 2.0.0
Table of contents
- Defining a common interface
- Implementing the interface for each library
- Conclusions
- FAQ
- I don't use Jetpack Compose. What about the "old" Android View System?
- What about supporting specific library configurations, like Paparazzi's rendering mode?
- What if I want to use several libraries in the same project/module? How can I pick one library dynamically?
- What about parameterized cross-library screenshot tests?
- What if I use my own screenshot testing library? How to support it?
- Further resources
As observed in the previous blog post of this series, screenshot tests have a lot in common regardless of the library they're written with, namely:
Setting the device configuration (e.g. Locale, Ui Mode, Font scale...)
Setting the Subject Under Test (SUT): e.g. invoking the composable
Recording/Verifying the screenshot of the SUT.
In this blog post, we'll see, in broad terms, how AndroidUitestingUtils 2.0.0 uses those findings to support writing cross-library screenshot tests.
Defining a common interface
So we'll define a common interface for setting up the device configuration and the SUT, as well as for recording/verifying.
This interface must have no direct dependency on any screenshot library: we don't want cross-library screenshot tests to depend on libraries we might never use.
// 1: Define the common device configuration.
data class ScreenshotConfigForComposable(
val locale: String? = null,
val fontScale: FontSize? = null,
...
)
// 1: Pass common device configuration.
// To be set by each library internally
abstract class ScreenshotTestRuleForComposable(
open val config: ScreeenshotConfigForComposable,
): TestWatcher() {
// 2 & 3: Set the SUT & Record/verify snapshots
abstract fun snapshot(
name: String? = null,
composable: @Composable () -> Unit,
)
}
Implementing the interface for each library
Now we'll implement that interface for Paparazzi, Dropshots & Roborazzi 1.
To maintain dependency separation, each implementation is enclosed within its own module, e.g. :paparazzi, :dropshots, :roborazzi, etc. and only depends on its corresponding screenshot library โ :paparazzi on Paparazzi, :dropshots on Dropshots, etc., โ ensuring no cross-dependencies among them.
1 AndroidUiTestingUtils provides these implementations + one for Shot
Paparazzi
To access Paparazzi's classes, we declare its dependency in the gradle file of :paparazzi module.
dependencies {
api 'app.cash.paparazzi:paparazzi:1.3.1'
}
And use them in our TestRule
class PaparazziScreenshotTestRuleForComposable(
override val config: ScreeenshotConfigForComposable,
): ScreenshotTestRuleForComposable(config) {
// 1. Set the device configuration
private paparazzi: Paparazzi by lazy {
// Class in AndroidUiTestingUtils that
// converts screenshotConfigForComposable
// into something that Paparazzi understands
PaparazziForComposableTestRuleBuilder()
.applyScreenshotConfig(config)
.build()
}
override fun apply(
base: Statement?,
description: Description?,
) : Statement =
paparazzi.apply(statement, base)
// 2 & 3: Set the SUT & record/verify snapshots
override fun snapshot(
name: String? = null,
composable: @Composable () -> Unit,
){
paparazzi.snapshot(name, composable)
}
}
Check out the full code here
Dropshots
Same as before, we declare Dropshots dependency in the gradle file of :dropshots module
dependencies {
api 'com.dropbox.dropshots:dropshots:0.4.1'
}
class DropshotsScreenshotTestRuleForComposable(
override val config: ScreeenshotConfigForComposable,
): ScreenshotTestRuleForComposable(config) {
// 1. Set the configuration via TestRule
// This is provided by AndroidUiTestingUtils
private val activityScenarioRule by lazy {
ActivityScenarioForComposableRule(
config = config.toComposableConfig()
)
}
// Used for recording/verifying
private val dropshots: Dropshots by lazy {
Dropshots()
}
// Ensure both rules apply with RuleChain
override fun apply(
base: Statement?,
description: Description?,
) : Statement =
RuleChain
.outerRule(dropshots)
.around(activityScenarioRule)
.apply(base, description)
}
// 2 & 3: Set the SUT & record/verify snapshots
override fun snapshot(
name: String? = null,
composable: @Composable () -> Unit,
){
// 2: Set the SUT and return it as ComposeView
val composeView =
activityScenarioRule
.setContent(composable)
.composeView
// 3: Record/verify the ComposeView
dropshots.assertSnapshot(
view = composeView,
name = name,
)
}
}
Check out the full code here
Roborazzi
Analogue to Paparazzi and Dropshots
dependencies {
api 'io.github.takahirom.roborazzi:roborazzi:1.5.0-rc-1'
}
class RoborazziScreenshotTestRuleForComposable(
override val config: ScreeenshotConfigForComposable,
): ScreenshotTestRuleForComposable(config) {
// 1. Set the configuration via TestRule
// This is provided by AndroidUiTestingUtils
// and contains a ComposeRule too
private val activityScenarioRule by lazy {
RobolectricActivityScenarioForComposableRule(
config = config.toComposableConfig()
)
}
private val filePathGenerator: FilePathGenerator =
FilePathGenerator()
override fun apply(
base: Statement?,
description: Description?,
): Statement =
activityScenarioRule.apply(base, description)
// 2 & 3: Set the SUT & record/verify snapshots
override fun snapshot(
name: String? = null,
composable: @Composable () -> Unit,
){
val screenshotPath =
filePathGenerator(
parent = DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH,
fileName = name,
)
// 2: Set the SUT
activityScenarioRule
.setContent(composable)
// 3: Record/verify with the ComposeRule
.composeRule
.onRoot()
.captureRoboImage(
filePath = screenshotPath,
)
}
}
Check out the full code here
Unfortunately, that's not enough to support Roborazzi. We still need to address 2 points:
Enable Robolectric Native Graphics mode
Use the RobolectricTestRunner
Supporting Robolectric Native Graphics (RNG)
The documented way to enable RNG is through Robolectric's @GraphicsMode(NATIVE)
annotation.
That means, cross-library tests would require this annotation even though Roborazzi or any other RNG-based libraries aren't used. All tests would depend on Robolectric.
This is suboptimal, and a better solution exists: one can enable RNG via gradle1 instead, like this
testOptions {
unitTests.all {
systemProperty 'robolectric.graphicsMode', 'NATIVE'
}
}
In doing so, we no longer require the @GraphicsMode(NATIVE)
annotation, avoiding a direct dependency on Robolectric.
1 Beware this enables RNG for all your Robolectric tests, though
Supporting RobolectricTestRunner
The need for the RobolectricTestRunner creates a direct dependency on Robolectric in common code.
Google's AndroidJUnitRunner supports shared tests: tests that can run on both the JVM and emulators/physical devices.
For that, it detects in which environment the tests run, and delegates to the RobolectricTestRunner on the JVM and to the AndroidJUnit4ClassRunner otherwise.
Interestingly, it uses reflection to sidestep a direct dependency on RobolectricTestRunner and, therefore, Robolectric.
Thus, the issue is that we cannot use the AndroidJUnitRunner directly because Paparazzi is not compatible with the RobolectricTestRunner 1.
Thus, we need to customize Google's AndroidJUnitRunner accordingly:
On the JVM
With Paparazzi, use the standard JUnit4 TestRunner
BlockJUnit4ClassRunner
With Roborazzi, use the
RobolectricTestRunner
On-device
- Use the
AndroidJUnit4ClassRunner
- Use the
So we create our own CrossLibraryScreenshotTestRunner based on Google's AndroidJUnitRunner, and tweak the necessary parts as follows:
private static boolean runsOnJvm() {
return !System.getProperty("java.runtime.name")
.toLowerCase()
.contains("android")
}
private static String getRunnerClassName() {
...
if (runsOnJvm()) {
...
// If the project has Paparazzi as a dependency, we
// assume it is used for JVM screenshot testing:
// Robolectric could be used for standard unit tests too.
if (hasClass("app.cash.paparazzi.Paparazzi")) {
return "org.junit.runners.BlockJUnit4ClassRunner";
}
if (hasClass("org.robolectric.RobolectricTestRunner")) {
return "org.robolectric.RobolectricTestRunner";
}
}
return "androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner";
}
Links with the full code below:
- Google's AndroidJUnitRunner
- AndroidUiTestingUtils CrossLibraryScreenshotTestRunner
This TestRunner belongs to cross-library common code, but we circumvent direct dependencies on Robolectric and Paparazzi through reflection.
Now, we can make cross-library screenshot tests work by using @RunWith(CrossLibraryScreenshotTestRunner::java.class)
1 Paparazzi is not compatible with RobolectricTestRunner #1089, #425
Deciding which library executes the tests
So far we've seen that, to achieve cross-library screenshot tests, we need:
An implementation of the ScreenshotTestRuleForComposable for each library we want to use
Use
@RunWith(CrossLibraryScreenshotTestRunner::java.class)
The last step is to create a special ScreenshotTestRuleForComposable that enables us to choose which of the previous ScreenshotTestRuleForComposable implementations we delegate to.
The ScreenshotLibraryTestRuleForComposable of AndroidUiTestingUtils simplifies that by encapsulating the delegation boilerplate code and providing the getScreenshotLibraryTestRule()
to decide the ScreenshotTestRuleForComposable that executes the tests under the hood
class MyScreenshotLibraryTestRuleForComposable(
override val config: ScreenshotConfigForComposable,
) : ScreenshotLibraryTestRuleForComposable(config) {
fun getScreenshotLibraryTestRule(
config: ScreenshotConfigForComposable,
): ScreenshotTestRuleForComposable =
// A ScreenshotTestRuleForComposable that we'll
// use to run our screenshot tests, e.g.
roborazziTestRuleForComposable
}
Check out
ScreenshotLibraryTestRuleForComposable
full code here
Let's see how to use it!
A cross-library screenshot test example
With all the pieces in place, writing a cross-library screenshot test is as easy as this
// Required due to RNG-based libraries, e.g Roborazzi
@RunWith(CrossLibraryScreenshotTestRunner::class)
class CrossLibraryScreenshotTest {
@get:Rule
val rule =
// This TestRule also chooses
// the library to be used
MyScreenshotLibraryTestRuleForComposable(
config = ScreenshotConfigForComposable(
locale = "es",
fontScale = FontSize.HUGE,
...
)
)
@Test
fun snapComposable() {
rule.snapshot("myComposable") {
AppTheme { MyComposable() }
}
}
}
To execute the test, run the corresponding record/verify commands of the library that MyScreenshotLibraryTestRuleForComposable picks under the hood.
But, what if I want to use another screenshot library?
Executing tests with another library
To execute your screenshot tests with a distinct library, follow these steps1:
Configure the plugin of the new library as described in its README.md.
Implement
ScreenshotTestRuleForComposable
with that library or import the module(s) from AndroidUiTestingUtils that contain it.Make
getScreenshotLibraryTestRule()
insideScreenshotLibraryTestRuleForComposable
return the newScreenshotTestRuleForComposable
.Ensure all cross-library screenshot tests are placed in the right test folder:
unitTests (JVM tests, e.g. for Paparazzi & Roborazzi)
androidTests (on-device tests, e.g. for Dropshots & Shot)
That's it. As you can see, the screenshot tests don't need any rewriting!
1 It's also possible to pick the library dynamically, so steps 3-4 wouldn't be required.
See the corresponding section in FAQ
Conclusions
Writing cross-library screenshot tests brings some benefits if:
You are brand new to screenshot tests and want to experiment with libraries to see which ones fit your project's needs the best
You've got hundreds of screenshot tests and want to migrate to another library/solution. This new library might not be what you need, and you don't want to go through the trouble of rewriting all those tests if it doesn't work out.
We've seen that providing a cross-library solution comes with its difficulties, like supporting RNG-based libraries like Roborazzi.
Thankfully, AndroidUiTestingUtils tackles these challenges for us, so that we can concentrate on the writing of cross-library tests instead of worrying about choosing the "right" library.
FAQ
I don't use Jetpack Compose. What about the "old" Android View System?
AndroidUiTestingUtils also provides ScreenshotTestRuleForView
using the very same concept.
Have a look at DropshotsScreenshotTestRuleForView and this test example to see how it is done.
What about supporting specific library configurations, like Paparazzi's rendering mode?
I've skipped libraries' configurations for simplicity's sake.
For on-device libraries, it's very straightforward, as you can see in DropshotsConfig and DropshotsScreenshotTestRuleForComposable
But Roborazzi and Paparazzi need extra caring if used in shared tests: dependencies on Paparazzi and Roborazzi in shared code (i.e. debugImplementation
) will throw runtime errors when running shared tests on devices/emulators with e.g. Shot or Dropshots. AndroidUiTestingUtils uses extra mapper modules to define those libraries' config, for instance
dependencies {
// :mapper-paparazzi in shared test code (debug)
// :paparazzi only in JVM (test)
debugImplementation 'com.github.sergio-sastre.AndroidUiTestingUtils:mapper-paparazzi:2.0.0'
testImplementation 'com.github.sergio-sastre.AndroidUiTestingUtils:paparazzi:2.0.0'
}
Check out PaparazziConfig in :mapper-paparazzi
and PaparazziForComposableTestRuleBuilder in :paparazzi
for further details.
What if I want to use several libraries in the same project/module? How can I pick one library dynamically?
That's likely the case for shared screenshot tests: run them locally with Roborazzi/Paparazzi for fast feedback but with Shot/Dropshots on CI for solid testing.
To use 1 JVM and 1 on-device library, check SharedScreenshotLibraryTestRuleForComposable as well as this test rule, this gradle file and this example.
It's also possible to use 2+ JVM or 2+ on-device libraries, but you'll need to run the record/verify command together with a custom gradle property that selects the ScreenshotTestRule used to execute the screenshot tests at runtime. Check out this test rule, this gradle file and this example for more info.
What about parameterized cross-library screenshot tests?
It's possible too. However, RNG-based libraries need to run with ParameterizedRobolectricTestRunner. This TestRunner uses @ParameterizedRobolectricTestRunner.Parameters
instead of the standard @Parameterized.Parameters
from JUnit4, what makes it strongly coupled to Robolectric.
Nevertheless, AndroidUiTestingUtils also solves this issue.
Just use @RunWith(ParameterizedCrossLibraryScreenshotTestRunner::class)
together with JUnit4 @Parameterized.Parameters
and you're done. You can check it out here
What if I use my own screenshot testing library? How to support it?
Provide an implementation of the ScreenshotTestRuleForComposable that uses your own library
Make ScreenshotLibraryTestRuleForComposable return that implementation in its
getScreenshotLibraryTestRule(config: ScreenshotConfigForComposable)
And you're done!
Further resources
๐จโ๐ซ Mastering Screenshot Testing Libraries for Android: A Hands-on Workshop(Droidcon Berlin 2023) - Workshop analysing pros and cons of many libraries
๐ธ AndroidUiTestingUtils - A library with utilities to fully support Cross-Library screenshot testing.
๐ Android screenshot testing playground - Screenshot test examples with different libraries, as well as Cross-library screenshot tests for Compose and Android Views.
โ๏ธ Blog post series: The road to effective snapshot testing - Several articles to introduce screenshot testing and its benefits, as well as solutions to problems you might come across.
Special thanks to Pablo Gallego for reviewing this blog post!
If you are interested in the content I publish, follow me on Twitter or LinkedIn!