What
This PR adds support to screenshot tests in the :app
module. Each test will take screenshot for both the light and dark them variant.
1. Integrating with Shot
In order to be able to have screenshot tests we are integrating Shot as our screenshot testing tool.
Any test can have the ability to take screenshot by:
- Extending
shot.ScreenshotTest
- Calling
compareScreenhot(rule: ComposeTestRule)
.
This way we could easily have a screenshot test in this form:
class TypographyTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun paragraph() {
composeTestRule.setContent {
Theme {
Text(text = "This is a paragraph.", style = Theme.typography.paragraph)
}
}
compareScreenshot(rule = composeTestRule)
}
}
By doing this we then have a screenshot generated in our module under app/screenshots/com.fabiocarballo.designsystem.TypographyTest.label
2. Improving the API
At this point, we know how to make a screenshot test, however as we grow the number of tests we would be having a lot of structural duplication:
- Extending
ScreenshotTest
- Declaring the
ComposeTestRule
- Setting the composable content (and not forgetting to wrap it under our
Theme
)
- Comparing the results of the screenshot.
We can then extract this structure into a DesignSystemScreenshotTest
:
abstract class DesignSystemScreenshotTest: ScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
fun runScreenshotTest(content: @Composable () -> Unit) {
composeTestRule.setContent {
Theme(content = content)
}
compareScreenshot(composeTestRule)
}
}
You can see that all the structure was then passed into this abstract class
that every test class should extend. Another point to note is that all content should be run under the scope of this runScreenshotTest
. Let's see how our TypographyTest
would look like now:
class TypographyTest: DesignSystemScreenshotTest() {
@Test
fun paragraph() = runScreenshotTest {
Text(text = "This is a paragraph.", style = Theme.typography.paragraph)
}
@Test
fun label() = runScreenshotTest {
Text(text = "This is a label.", style = Theme.typography.label)
}
@Test
fun display() = runScreenshotTest {
Text(text = "This is a display.", style = Theme.typography.display)
}
}
The goal here was to make a test being almost as easy as just declare the composable that should be screenshotted.
By now, our screenshots would be:
| Label | Paragraph | Display |
|---|---|---|
| | | |
3. Add support to multi-theme
At this point, we have now the capability to easily generate our screenshots for our Light Theme. As a next step, we want to use the same codebase and automatically generate screenshot also to Dark Theme.
For that, we are going to enrich our DesignSystemScreenshotTest
with the capability to run parameterized tests. Hence, we are using Test Parameter Injector to build the parameterized behavior.
What we are going to do first is to declare a private enum with the themes we want to parameterize with:
private enum class ThemeMode { LIGHT, DARK }
Then we are going to declare it as a test parameter so that each test will run with both LIGHT
and DARK
. This is done by adding a field annotated with @TestParameter
and by adding integrating with the TestParameterInjector
test runner.
@RunWith(TestParameterInjector::class)
abstract class DesignSystemScreenshotTest : ScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@TestParameter
private lateinit var themeMode: ThemeMode
fun runScreenshotTest(
content: @Composable () -> Unit
) {
composeTestRule.setContent {
Theme(
isSystemInDarkMode = themeMode == ThemeMode.DARK,
content = content
)
}
compareScreenshot(
rule = composeTestRule,
)
}
private enum class ThemeMode { LIGHT, DARK }
}
However, we still have one small problem: Shot
default behavior is to name the screenshot as "ClassName_MethodName". This way, we actually record the screenshots for the test in both modes, but the last run always overrides the first one.
To fix that, we are going to generate the screenshot name by ourselves with the help of this helper method:
private fun extractClassAndMethodName(): String {
val stack = Throwable().stackTrace
stack.forEach { element ->
try {
val clazz = Class.forName(element.className)
val method = clazz.getMethod(element.methodName)
if (method.annotations.any { it.annotationClass == Test::class }) {
return "${clazz.canonicalName}_${method.name}"
}
} catch (ignored: NoSuchMethodException) {
// do nothing
} catch (ignored: ClassNotFoundException) {
// do nothing
}
}
error("Couldn't parse the name")
}
This method will simply use the StackTrace
to figure out what is the test class and method name. We can then use this information together with the ThemeMode
that is being used for the test to generate a test name as:
- TypographyTest_paragraph_light
- TypographyTest_paragraph_dark
Below you have the final version of the DesignSystemScreenshotTest
:
@RunWith(TestParameterInjector::class)
abstract class DesignSystemScreenshotTest : ScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@TestParameter
private lateinit var themeMode: ThemeMode
fun runScreenshotTest(
content: @Composable () -> Unit
) {
composeTestRule.setContent {
Theme(
isSystemInDarkMode = themeMode == ThemeMode.DARK,
content = content
)
}
val name = "${extractClassAndMethodName()}_${themeMode.name.lowercase()}"
compareScreenshot(
rule = composeTestRule,
name = name
)
}
private enum class ThemeMode { LIGHT, DARK }
}
And that is it, when you use run your screenshot tests you will generate both Light and Dark mode screenshots in one go. In our example, the generate screenshots are the following:
| Label | Paragraph | Display |
|---|---|---|
| | |
| | | |