Slide 1

Slide 1 text

仕組みから理解する! Composeプレビューを様々なバリエーションで スクリーンショットテストしよう 2024.09.12 (DroidKaigi 2024) Sumio Toyama (sumio_tym) Understand the mechanism! Let's do screenshots tests of Compose Previews with various variations

Slide 2

Slide 2 text

About Me 2  Sumio Toyama (外山 純生) @sumio_tym (X) / @sumio (GitHub)  Work at DeNA SWET Group on Android App Development / Test Automation (SWET = Software Engineer in Test)

Slide 3

Slide 3 text

Talk Summary 3 Explain how to take various screenshots of Composable Preview Functions using Roborazzi for Visual Regression Tests (VRT). Will also explain the roles of related tools and mechanisms to make it easier for everyone to implement.

Slide 4

Slide 4 text

Topics Not Covered in This Session 4  Operation of the screenshots taken  Timing for taking the screenshots  Selection of image comparison tools for VRT  Building and operating CI (Continuous Integration) See https://github.com/DeNA/android-modern-architecture-test-handson/blob/main/docs/handson/VisualRegressionTest_CI.md for CI operation know-how.

Slide 5

Slide 5 text

Agenda 5 1. Background (What We Aim to Achieve) 2. Roles of Related Tools 3. Taking Screenshots of Composable Preview Functions 4. Capturing Various Types of Screenshots 5. Differences from Google's Compose Preview Screenshot Testing Methodology 6. Conclusion

Slide 6

Slide 6 text

Agenda 6 1. Background (What We Aim to Achieve) 2. Roles of Related Tools 3. Taking Screenshots of Composable Preview Functions 4. Capturing Various Types of Screenshots 5. Differences from Google's Compose Preview Screenshot Testing Methodology 6. Conclusion

Slide 7

Slide 7 text

Definitions of Terms 7 Screenshot Test  Automated testing, such as saving screenshots of an application  A human must visually verify the correctness of the saved screenshots

Slide 8

Slide 8 text

8 Visual Regression Test (VRT)  Automated test that compares the results of screenshot tests to each other to ensure that there are no unintended differences in the screenshot images  A visual check by a human is still required to see if the difference is intentional or not  Screenshot testing tools often also provide diff detection Definitions of Terms

Slide 9

Slide 9 text

History of screenshot testing 9 The Age of Android View  Write test code to interact with the UI and show the desired screen  Take the screenshots by using Instrumented Test Hard to keep it operational ◆ Hard to write test code ◆ Tests tend to flaky due to async processing ◆ Very slow to execute on real devices and/or emulators and difficult to incorporate into CI

Slide 10

Slide 10 text

10 The Age of Jetpack Compose  The introduction of the Preview function has led to the habit of checking screen layouts on the fly  Saving a screenshot of the Preview function has also become popular  It's now possible to take screenshots in Local Test The foundation for easy operation has been established! ◆ No test code is required with the Preview function ◆ Easily integrated into CI since it can be executed quickly in the JVM History of screenshot testing

Slide 11

Slide 11 text

What We Aim to Achieve in This Session 11  Implement screenshot test based on the screenshot of the Preview function  Take a screenshot of the various variations such as:  Night mode and Light mode  Tablet and Smartphone  Various locales  Write as little test code as possible

Slide 12

Slide 12 text

Summary So Far 12  Clarified the difference between screenshot testing and Visual Regression Tests  With Jetpack Compose now in use, the foundation for easy operation of screenshot testing and VRT has been established  Clarified what we want to achieve in this session

Slide 13

Slide 13 text

Agenda 13 1. Background (What We Aim to Achieve) 2. Roles of Related Tools 3. Taking Screenshots of Composable Preview Functions 4. Capturing Various Types of Screenshots 5. Differences from Google's Compose Preview Screenshot Testing Methodology 6. Conclusion

Slide 14

Slide 14 text

What We Aim to Achieve (rough sketch) 14 @Preview @Preview @Preview ・ ・ ・ @Test fun takeScreenShot() { ... } ①Collect the Preview Functions ②Take a screenshot of each Preview function collected ③And even take screenshots of various variations. (e.g., night mode, tablet screen, German locale, etc.)

Slide 15

Slide 15 text

15 @Preview @Preview @Preview ・ ・ ・ @Test fun takeScreenShot() { ... } ①Collect the Preview Functions ②Take a screenshot of each Preview function collected ③And even take screenshots of various variations. (e.g., night mode, tablet screen, German locale, etc.)

Slide 16

Slide 16 text

①Libs for Collecting Preview Functions 16  There are some libraries for collecting preview functions  In practice, one of these libraries will be chosen for your use

Slide 17

Slide 17 text

①Libs for Collecting Preview Functions 17 Showkase https://github.com/airbnb/Showkase  Library that generates a UI catalog browser (ShowkaseBrowser) with Preview functions, fonts, colors, etc., and allows browsing on Android devices  Provides API to retrieve the Preview functions (@Composable () -> Unit) collected by Showkase  Only some properties of @Preview can be obtained  Realized by using an annotation processor

Slide 18

Slide 18 text

Code Snippet for Showkase 18 // In the app module @ShowkaseRoot class MyShowkaseRootModule : ShowkaseRootModule // Retrieve the list of Preview composable functions val componentList: List = Showkase.getMetadata().componentList val previewFunctionList: List<@Composable () -> Unit> = componentList.map { it.component }

Slide 19

Slide 19 text

Note: Properties of ShowkaseBrowserComponent 19 Property Type Corresponding @Preview property componentKey String group String group componentName String name componentKDoc String component @Composable () -> Unit styleName String? isDefaultStyle Boolean widthDp Int? widthDp heightDp Int? heightDp tags List extraMetaData List

Slide 20

Slide 20 text

20 Property Type Corresponding @Preview property componentKey String group String group componentName String name componentKDoc String component @Composable () -> Unit styleName String? isDefaultStyle Boolean widthDp Int? widthDp heightDp Int? heightDp tags List extraMetaData List Of the properties @Preview has, only these 4 can be handled by Showkase Note: Properties of ShowkaseBrowserComponent

Slide 21

Slide 21 text

21 Composable Preview Scanner https://github.com/sergio-sastre/ComposablePreviewScanner  Relatively new tool (1st released on 2024-06-10)  Library dedicated to collecting Preview functions and their metadata (e.g. properties of @Preview)  Allows to access all @Preview properties  Also allows to access other annotations attached to the @Preview function  Realized by using the JVM's reflection functionality ①Libs for Collecting Preview Functions

Slide 22

Slide 22 text

Code Snippet for Composable Preview Scanner 22 // Retrieve the list of Preview composable functions val componentList: List> = AndroidComposablePreviewScanner() .scanPackageTrees("com.example.droidkaigi", ...) .getPreviews() // ComposablePreview implements a @Composable invoke(), so // you can call the preview function by calling invoke(). // If you intentionally store them in a List<@Composable () -> Unit> type, it would look like the following val previewFunctionList: List<@Composable () -> Unit> = composablePreviewList.map { { it() } }

Slide 23

Slide 23 text

Note: Properties of AndroidPreviewInfo 23 Property Type Corresponding @Preview property name String name group String group apiLevel Int apiLevel widthDp Int widthDp heightDp Int heightDp locale String locale fontScale Float fontScale showSystemUi Boolean showSystemUi showBackground Boolean showBackground backgroundColor Long backgroundColor uiMode Int uiMode device String device wallpaper Int wallpaper

Slide 24

Slide 24 text

Note: Properties of AndroidPreviewInfo 24 Property Type Corresponding @Preview property name String name group String group apiLevel Int apiLevel widthDp Int widthDp heightDp Int heightDp locale String locale fontScale Float fontScale showSystemUi Boolean showSystemUi showBackground Boolean showBackground backgroundColor Long backgroundColor uiMode Int uiMode device String device wallpaper Int wallpaper All properteis of @Preview can be handled

Slide 25

Slide 25 text

Recap of ①Libs for Collecting Preview Functions 25  Introduced 2 libraries  Showkase  Composable Preview Scanner  Basic functionality is the same for both  Differ in the extent to which @Preview properties can be accessed

Slide 26

Slide 26 text

26 @Preview @Preview @Preview ・ ・ ・ @Test fun takeScreenShot() { ... } ①Collect the Preview Functions ②Take a screenshot of each Preview function collected ③And even take screenshots of various variations. (e.g., night mode, tablet screen, German locale, etc.)

Slide 27

Slide 27 text

②Libs for Taking Screenshot 27  Taking the screenshots is achieved by combining multiple libraries  All of the libraries introduced here are required

Slide 28

Slide 28 text

②Libs for Taking Screenshot 28 ComposeTestRule https://developer.android.com/develop/ui/compose/testing/apis  Official Jetpack Compose Library  Can test a Composable function unit by displaying on the screen  UI interaction, layout validation, etc.

Slide 29

Slide 29 text

29 Robolectric https://robolectric.org/  Allows to run Android tests as Local Tests (on the JVM of your development PC)  Executes Android Framework code as-is (as much as possible) in the JVM  Also works with Jetpack Compose  Recently, Graphics related classes are also executed as-is (Robolectric Native Graphics (RNG))  Hardware rendering is also supported to get more realistic rendering results ②Libs for Taking Screenshot

Slide 30

Slide 30 text

30 Roborazzi https://takahirom.github.io/roborazzi/top.html  Library for screenshot testing with Robolectric  Obtains screenshots closer to the actual device due to improved Robolectric functionality  Supports Jetpack Compose (work with ComposeTestRule) ②Libs for Taking Screenshot

Slide 31

Slide 31 text

Code Snippet for Screenshot Testing a Single Composable Func. 31 @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyScreenshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun test() { composeTestRule.setContent { MyComposable() } composeTestRule.onRoot().captureRoboImage() }}

Slide 32

Slide 32 text

Code Snippet for Screenshot Testing a Single Composable Func. 32 @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyScreenshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun test() { composeTestRule.setContent { MyComposable() } composeTestRule.onRoot().captureRoboImage() }} Declaration to use Robolectric with Native Graphics enabled

Slide 33

Slide 33 text

33 @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyScreenshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun test() { composeTestRule.setContent { MyComposable() } composeTestRule.onRoot().captureRoboImage() }} Declaration of ComposeTestRule Code Snippet for Screenshot Testing a Single Composable Func.

Slide 34

Slide 34 text

34 @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyScreenshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun test() { composeTestRule.setContent { MyComposable() } composeTestRule.onRoot().captureRoboImage() }} Composable function under test Code Snippet for Screenshot Testing a Single Composable Func.

Slide 35

Slide 35 text

35 @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyScreenshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun test() { composeTestRule.setContent { MyComposable() } composeTestRule.onRoot().captureRoboImage() }} Take screenshot of MyComposable() Code Snippet for Screenshot Testing a Single Composable Func.

Slide 36

Slide 36 text

Parameterized Test with Robolectric 36 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyParameterizedTest(private val testCase: TestCase) { @Test fun test() { doSomeTest(testCase.text) } companion object { data class TestCase(private val text: String) @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun params() = listOf(TestCase("Hello"), TestCase("こんにちは")) }}

Slide 37

Slide 37 text

Parameterized Test with Robolectric 37 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyParameterizedTest(private val testCase: TestCase) { @Test fun test() { doSomeTest(testCase.text) } companion object { data class TestCase(private val text: String) @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun params() = listOf(TestCase("Hello"), TestCase("こんにちは")) }} ①Declare Runner for Parameterized Test

Slide 38

Slide 38 text

Parameterized Test with Robolectric 38 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyParameterizedTest(private val testCase: TestCase) { @Test fun test() { doSomeTest(testCase.text) } companion object { data class TestCase(private val text: String) @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun params() = listOf(TestCase("Hello"), TestCase("こんにちは")) }} ②Declare a list of parameters to be passed to the constructor of the test class

Slide 39

Slide 39 text

Parameterized Test with Robolectric 39 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyParameterizedTest(private val testCase: TestCase) { @Test fun test() { doSomeTest(testCase.text) } companion object { data class TestCase(private val text: String) @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun params() = listOf(TestCase("Hello"), TestCase("こんにちは")) }} ③A parameter passed as constructor argments

Slide 40

Slide 40 text

Parameterized Test with Robolectric 40 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyParameterizedTest(private val testCase: TestCase) { @Test fun test() { doSomeTest(testCase.text) } companion object { data class TestCase(private val text: String) @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun params() = listOf(TestCase("Hello"), TestCase("こんにちは")) }} ④Test with the parameter passed

Slide 41

Slide 41 text

Recap of ②Libs for Taking Screenshot 41  Using a combination of ComposeTestRule, Robolectric, and Roborazzi, a screenshot of the Composable function can be taken  ParameterizedRobolectricTestRunner allows to run test for each element in the list of parameters used for testing

Slide 42

Slide 42 text

Agenda 42 1. Background (What We Aim to Achieve) 2. Roles of Related Tools 3. Taking Screenshots of Composable Preview Functions 4. Capturing Various Types of Screenshots 5. Differences from Google's Compose Preview Screenshot Testing Methodology 6. Conclusion

Slide 43

Slide 43 text

Combining what we have so far... 43 If we can prepare a list of preview functions to take screenshots, we should be able to take screenshots of each preview function by using ParameterizedRobolectricTestRunner

Slide 44

Slide 44 text

44 @Preview @Preview @Preview ・ ・ ・ @Test fun takeScreenShot() { ... } ①Collect the Preview Functions ②Take a screenshot of each Preview function collected ③And even take screenshots of various variations. (e.g., night mode, tablet screen, German locale, etc.)

Slide 45

Slide 45 text

Preparation for using Robolectric and Roborazzi 45 plugins { id("io.github.takahirom.roborazzi") version "1.26.0" apply false }  Top-level build.gradle.kts # Fix the screenshot output directory roborazzi.record.filePathStrategy=relativePathFromRoborazziContextOutputDi rectory # Save screenshots when running tests in Android Studio roborazzi.test.record=true  gradle.properties

Slide 46

Slide 46 text

Preparation for using Robolectric and Roborazzi 46 plugins { id("io.github.takahirom.roborazzi") } android { testOptions { unitTests { isIncludeAndroidResources = true all { it.systemProperties( "roborazzi.output.dir" to rootProject.file("screenshots").absolutePath, "robolectric.pixelCopyRenderMode" to "hardware" ) } } } }  build.gradle.kts file in the app module

Slide 47

Slide 47 text

Preparation for using Robolectric and Roborazzi 47 plugins { id("io.github.takahirom.roborazzi") } android { testOptions { unitTests { isIncludeAndroidResources = true all { it.systemProperties( "roborazzi.output.dir" to rootProject.file("screenshots").absolutePath, "robolectric.pixelCopyRenderMode" to "hardware" ) } } } }  build.gradle.kts file in the app module Declarations required to use Robolectric

Slide 48

Slide 48 text

Preparation for using Robolectric and Roborazzi 48 plugins { id("io.github.takahirom.roborazzi") } android { testOptions { unitTests { isIncludeAndroidResources = true all { it.systemProperties( "roborazzi.output.dir" to rootProject.file("screenshots").absolutePath, "robolectric.pixelCopyRenderMode" to "hardware" ) } } } }  build.gradle.kts file in the app module Specify the output directory for screenshots

Slide 49

Slide 49 text

Preparation for using Robolectric and Roborazzi 49 dependencies { testImplementation("io.github.takahirom.roborazzi:roborazzi:1.26.0") testImplementation("io.github.takahirom.roborazzi:roborazzi- compose:1.26.0") testImplementation("org.robolectric:robolectric:4.13") } roborazzi { outputDir.set(rootProject.file("screenshots")) }  build.gradle.kts file in the app module (cont.)

Slide 50

Slide 50 text

Preparation for using Robolectric and Roborazzi 50 dependencies { testImplementation("io.github.takahirom.roborazzi:roborazzi:1.26.0") testImplementation("io.github.takahirom.roborazzi:roborazzi- compose:1.26.0") testImplementation("org.robolectric:robolectric:4.13") } roborazzi { outputDir.set(rootProject.file("screenshots")) }  build.gradle.kts file in the app module (cont.) Specify the output directory for screenshots here as well

Slide 51

Slide 51 text

Additional Preparation for using Showkase 51 plugins { id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false }  Top-level build.gradle.kts plugins { id("com.google.devtools.ksp") } dependencies { implementation("com.airbnb.android:showkase:1.0.3") ksp("com.airbnb.android:showkase-processor:1.0.3") } ksp { arg("skipPrivatePreviews", "true") }  build.gradle.kts file in all modules in which @Preview function is used

Slide 52

Slide 52 text

Additional Preparation for using Showkase 52 plugins { id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false }  Top-level build.gradle.kt plugins { id("com.google.devtools.ksp") } dependencies { implementation("com.airbnb.android:showkase:1.0.3") ksp("com.airbnb.android:showkase-processor:1.0.3") } ksp { arg("skipPrivatePreviews", "true") }  build.gradle.kts file in all modules in which @Preview function is used Configuration to ignore private @Preview function without error

Slide 53

Slide 53 text

Additional Preparation for using Showkase 53 @ShowkaseRoot class MyRootModule: ShowkaseRootModule  Declare empty class in the app module  Any class name is allowed  @ShowkaseRoot annotation is required  Must inherit ShowkaseRootModule

Slide 54

Slide 54 text

Code Snippet by using Showkase (1/3) 54 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) class ShowkasePreviewTest( private val component: ShowkaseBrowserComponent ) { @get:Rule val composeTestRule = createComposeRule() ...

Slide 55

Slide 55 text

Code Snippet by using Showkase (1/3) 55 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) class ShowkasePreviewTest( private val component: ShowkaseBrowserComponent ) { @get:Rule val composeTestRule = createComposeRule() ... Use Parameterized Test

Slide 56

Slide 56 text

Code Snippet by using Showkase (1/3) 56 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) class ShowkasePreviewTest( private val component: ShowkaseBrowserComponent ) { @get:Rule val composeTestRule = createComposeRule() ... Test under Pixel7 screen

Slide 57

Slide 57 text

Code Snippet by using Showkase (1/3) 57 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) class ShowkasePreviewTest( private val component: ShowkaseBrowserComponent ) { @get:Rule val composeTestRule = createComposeRule() ... Passed data collected by Showkase as parameters

Slide 58

Slide 58 text

Code Snippet by using Showkase (2/3) 58 @Test fun test() { val filePath = "${component.componentKey}.png" composeTestRule.setContent { CompositionLocalProvider( LocalInspectionMode provides true ) { component.component() } } composeTestRule.onRoot().captureRoboImage(filePath) } ...

Slide 59

Slide 59 text

Code Snippet by using Showkase (2/3) 59 @Test fun test() { val filePath = "${component.componentKey}.png" composeTestRule.setContent { CompositionLocalProvider( LocalInspectionMode provides true ) { component.component() } } composeTestRule.onRoot().captureRoboImage(filePath) } ... Avoid duplicate file names for screenshots

Slide 60

Slide 60 text

Code Snippet by using Showkase (2/3) 60 @Test fun test() { val filePath = "${component.componentKey}.png" composeTestRule.setContent { CompositionLocalProvider( LocalInspectionMode provides true ) { component.component() } } composeTestRule.onRoot().captureRoboImage(filePath) } ... Ensure the same result as displaying @Preview in Android Studio

Slide 61

Slide 61 text

Code Snippet by using Showkase (2/3) 61 @Test fun test() { val filePath = "${component.componentKey}.png" composeTestRule.setContent { CompositionLocalProvider( LocalInspectionMode provides true ) { component.component() } } composeTestRule.onRoot().captureRoboImage(filePath) } ... Preview function stored in ShowkaseBrowserComponent

Slide 62

Slide 62 text

Code Snippet by using Showkase (3/3) 62 companion object { @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun params(): Iterable = Showkase.getMetadata().componentList } } }

Slide 63

Slide 63 text

Code Snippet by using Showkase (3/3) 63 companion object { @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun params(): Iterable = Showkase.getMetadata().componentList } } } Returns a list of ShowkaseBrowserComponent collected by Showkase

Slide 64

Slide 64 text

Additional Notes for the Code Snippet 64 @Config(qualifiers = ...)  Robolectric's function to specify screen size, etc. filePath = "${component.componentKey}.png"  componentKey: A value that uniquely distinguishes ShowkaseBrowserComponent  Prevents screenshots of different Preview functions from being overwritten LocalInspectionMode  This value is set to true when previewing in Android Studio  Set to true if you want the result to be the same as the Android Studio preview

Slide 65

Slide 65 text

Using Composable Preview Scanner 65  The concept is the same as Showkase, but  Different test parameter acquisition for Composable Preview Scanner  Need to use AndroidPreviewScreenshotIdBuilder to specify screenshot file name  Roborazzi Gradle Plugin provides a mechanism to simplify these, so use it as-is or customize it to your needs

Slide 66

Slide 66 text

Additional Preparation for Using Composable Preview Scanner 66 dependencyResolutionManagement { repositories { ... maven { url = URI("https://jitpack.io") } } }  settings.gradle.kts

Slide 67

Slide 67 text

Additional Preparation for Using Composable Preview Scanner 67 dependencies { testImplementation("io.github.takahirom.roborazzi:roborazzi-compose- preview-scanner-support:1.26.0") testImplementation("com.github.sergio- sastre.ComposablePreviewScanner:android:0.3.0") }  build.gradle.kts in the app module

Slide 68

Slide 68 text

Additional Preparation for Using Composable Preview Scanner 68 roborazzi { ... @OptIn(ExperimentalRoborazziApi::class) generateComposePreviewRobolectricTests { enable = true // Specifies packages to scan preview functions packages = listOf("com.example.droidkaigi") } }  build.gradle.kts in the app module

Slide 69

Slide 69 text

Running Test by Using Composable Preview Scanner 69  The build will generate a class called RoborazziPreviewParameterizedTests, which is used to run tests  The following @Preview properties are supported by default  uiMode (only night mode / light mode)  locale  fontScale

Slide 70

Slide 70 text

70  If you combine the Preview function collector with the ParameterizedRobolectricTestRunner, you can test screenshots of all collected Preview functions  If you use Composable Preview Scanner, you can automatically generate screenshot tests just by setting up a build scripts Summary So Far

Slide 71

Slide 71 text

Agenda 71 1. Background (What We Aim to Achieve) 2. Roles of Related Tools 3. Taking Screenshots of Composable Preview Functions 4. Capturing Various Types of Screenshots 5. Differences from Google's Compose Preview Screenshot Testing Methodology 6. Conclusion

Slide 72

Slide 72 text

72 @Preview @Preview @Preview ・ ・ ・ @Test fun takeScreenShot() { ... } ①Collect the Preview Functions ②Take a screenshot of each Preview function collected ③And even take screenshots of various variations. (e.g., night mode, tablet screen, German locale, etc.)

Slide 73

Slide 73 text

Review of @Preview annotations @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES ) 73 The screenshot is from Now in Android App licensed under Apache License (Version 2.0). https://github.com/android/nowinandroid  Various variations of the preview image can be checked by adding optional parameters to @Preview

Slide 74

Slide 74 text

74 The screenshot is from Now in Android App licensed under Apache License (Version 2.0). https://github.com/android/nowinandroid @Preview(fontScale = 2f) Review of @Preview annotations

Slide 75

Slide 75 text

75  Multiple @Previews can be specified @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(fontScale = 2f) @Composable fun PreviewMyComposable() { ... }  Custom annotations can be defined to group multiple @Previews @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) annotation class NightModeVariationPreview @NightModeVariationPreview @Composable fun PreviewMyComposable() { ... } Review of @Preview annotations

Slide 76

Slide 76 text

Strategy for taking various screenshots 76  Take screenshots as indicated in @Preview's property  If the custom annotation shown on the previous slide is used, take 2 screenshots, night mode and light mode  Can be achieved by changing the configs for taking screenshot based on the @Preview's property just before calling each captureRoboImage() @NightModeVariationPreview @Composable fun PreviewMyComposable() { ... }

Slide 77

Slide 77 text

77  Custom annotations can be useful for taking multiple patterns of screenshots uniformly for all @Preview functions  If you want to take screenshots in night mode for all Preview functions, simply add the @NightModeVariationPreview to all Preview functions instead of the normal @Preview Strategy for taking various screenshots

Slide 78

Slide 78 text

78  The strategy can be realized if the following are implemented  Access to the @Preview properties of the collected functions  Take screenshots in various configuration (night mode, font scale, etc.) Strategy for taking various screenshots

Slide 79

Slide 79 text

Access to @Preview properties 79  Showkase  Only group, name, widthDp, heightDp can be accessed  To pass other optional information, it is necessary to encode it into a group property, etc.  Example: @Preview(group="ja-night", locale = "ja", uiMode = UI_MODE_NIGHT_YES)  Composable Preview Scanner  All properties can be accessed  If you want to use the full @Preview information, this library is recommended

Slide 80

Slide 80 text

Take screenshots in various configuration 80  Available means are as follows  Original functionality of Jetpack Compose  Functions of Robolectric  Functions of ComposeTestRule

Slide 81

Slide 81 text

Changing config using Compose's original functionality 81  Use of CompositionLocalProvider @Test fun test() { composeTestRule.setContent { val density = LocalDensity.current val newDensity = Density( density = density.density, fontScale = density.fontScale * fontScale ) CompositionLocalProvider(LocalDensity provides newDensity) { myComposable() } } composeTestRule.onRoot().captureRoboImage() } Assume this fontScale is given externally

Slide 82

Slide 82 text

@Test fun test() { composeTestRule.setContent { val density = LocalDensity.current val newDensity = Density( density = density.density, fontScale = density.fontScale * fontScale ) CompositionLocalProvider(LocalDensity provides newDensity) { myComposable() } } composeTestRule.onRoot().captureRoboImage() } 82 Override LocalDensity to change font scale Changing config using Compose's original functionality  Use of CompositionLocalProvider

Slide 83

Slide 83 text

Changing config using Robolectric's functionality 83  Use of RuntimeEnvironment.setQualifiers()  Configuration specified in @Config can be changed dynamically @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) class MyTest(private val testCase: TestCase) { ...

Slide 84

Slide 84 text

84 @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) class MyTest(private val testCase: TestCase) { ... "w411dp-h914dp-normal-long-notround-any-420dpi-keyshidden-nonav" Changing config using Robolectric's functionality  Use of RuntimeEnvironment.setQualifiers()  Configuration specified in @Config can be changed dynamically

Slide 85

Slide 85 text

Note: Possible values for @Config qualifiers 85 Configuration Qualifier values (Example) Description Locale en, en-US, fr-rCA, ... language code / region code Layout Direction ldltr, ldrtl left-to-right / right-to-left Screen Orientation port, land portrait / landscape orientation Night Mode night, notnight Night Mode (Dark Mode)  Same as Android Resource Directory naming rule(e.g., values-ja) https://developer.android.com/guide/topics/resources/providing-resources.html#QualifierRules  Specify from the top of the table referenced from the URL above  Example: land-night (Landscape and Night Mode)

Slide 86

Slide 86 text

Changing config using Robolectric's functionality 86  Use of RuntimeEnvironment.setQualifiers()  A qualifier with a leading + can be used to override a part of configuration declared in @Config @Config(qualifiers = "xlarge-port") class MyTest { @get:Rule val composeTestRule = createAndroidComposeRule() ... @Test fun testLandscape() { RuntimeEnvironment.setQualifiers("+land") composeTestRule.activityRule.scenario.recreate() // become xlarge-land (Landscape mode) here }}

Slide 87

Slide 87 text

Changing config using Robolectric's functionality 87 @Config(qualifiers = "xlarge-port") class MyTest { @get:Rule val composeTestRule = createAndroidComposeRule() ... @Test fun testLandscape() { RuntimeEnvironment.setQualifiers("+land") composeTestRule.activityRule.scenario.recreate() // become xlarge-land (Landscape mode) here }} Changed from xlarge-port to xlarge-land  Use of RuntimeEnvironment.setQualifiers()  A qualifier with a leading + can be used to override a part of configuration declared in @Config

Slide 88

Slide 88 text

88 @Config(qualifiers = "xlarge-port") class MyTest { @get:Rule val composeTestRule = createAndroidComposeRule() ... @Test fun testLandscape() { RuntimeEnvironment.setQualifiers("+land") composeTestRule.activityRule.scenario.recreate() // become xlarge-land (Landscape mode) here }} Remember to recreate the Activity to apply changes Changing config using Robolectric's functionality  Use of RuntimeEnvironment.setQualifiers()  A qualifier with a leading + can be used to override a part of configuration declared in @Config

Slide 89

Slide 89 text

89 @Config(qualifiers = "xlarge-port") class MyTest { @get:Rule val composeTestRule = createAndroidComposeRule() ... @Test fun testLandscape() { RuntimeEnvironment.setQualifiers("+land") composeTestRule.activityRule.scenario.recreate() // become xlarge-land (Landscape mode) here }} Changing config using Robolectric's functionality Use this instead of createComposeRule to allow access to activityRule  Use of RuntimeEnvironment.setQualifiers()  A qualifier with a leading + can be used to override a part of configuration declared in @Config

Slide 90

Slide 90 text

90 @get:Rule val composeTestRule = createAndroidComposeRule() ... @Test fun test() { // Assume that widthDp and heightDp are given externally setDisplaySize(widthDp, heightDp) composeTestRule.activityRule.scenario.recreate() composeTestRule.setContent { ... } composeTestRule.onRoot().captureRoboImage() }  Use of ShadowDisplay for changing screen size Changing config using Robolectric's functionality

Slide 91

Slide 91 text

91 @get:Rule val composeTestRule = createAndroidComposeRule() ... @Test fun test() { // Assume that widthDp and heightDp are given externally setDisplaySize(widthDp, heightDp) composeTestRule.activityRule.scenario.recreate() composeTestRule.setContent { ... } composeTestRule.onRoot().captureRoboImage() } will be explained in later slides  Use of ShadowDisplay for changing screen size Changing config using Robolectric's functionality

Slide 92

Slide 92 text

92 @get:Rule val composeTestRule = createAndroidComposeRule() ... @Test fun test() { // Assume widthDp and heightDp are given externally setDisplaySize(widthDp, heightDp) composeTestRule.activityRule.scenario.recreate() composeTestRule.setContent { ... } composeTestRule.onRoot().captureRoboImage() } Recreate the Activity to apply changes  Use of ShadowDisplay for changing screen size Changing config using Robolectric's functionality

Slide 93

Slide 93 text

93 private fun setDisplaySize(widthDp: Int?, heightDp: Int?) { val display = ShadowDisplay.getDefaultDisplay() val density = context.resources.displayMetrics.density widthDp?.let { val widthPx = (widthDp * density).roundToInt() Shadows.shadowOf(display).setWidth(widthPx) } heightDp?.let { val heightPx = (heightDp * density).roundToInt() Shadows.shadowOf(display).setHeight(heightPx) } }  Use of ShadowDisplay for changing screen size Changing config using Robolectric's functionality

Slide 94

Slide 94 text

94 private fun setDisplaySize(widthDp: Int?, heightDp: Int?) { val display = ShadowDisplay.getDefaultDisplay() val density = context.resources.displayMetrics.density widthDp?.let { val widthPx = (widthDp * density).roundToInt() Shadows.shadowOf(display).setWidth(widthPx) } heightDp?.let { val heightPx = (heightDp * density).roundToInt() Shadows.shadowOf(display).setHeight(heightPx) } } Specify new screen size (in pixels)  Use of ShadowDisplay for changing screen size Changing config using Robolectric's functionality

Slide 95

Slide 95 text

Status of @Preview properties support so far 95 @Preview Property Tool to use API to use name - - group - - apiLevel - - widthDp Robolectric ShadowDisplay heightDp Robolectric ShadowDisplay locale Robolectric setQualifiers("+ja") fontScale LocalCompositionProvider LocalDensity showSystemUi - - showBackground - - backgroundColor - - uiMode Robolectric setQualifiers("+night") / setQualifiers("+television") device Robolectric setQualifiers(RobolectricDeviceQualifiers.Pixel7) wallpaper - -

Slide 96

Slide 96 text

Note: Font scale can also be changed by Robolectric 96 @Test fun test() { val fontScale = ... RuntimeEnvironment.setFontScale(fontScale) composeTestRule.activityRule.scenario.recreate() composeTestRule.setContent { myComposable() } composeTestRule.onRoot().captureRoboImage() }

Slide 97

Slide 97 text

Changing config using ComposeTestRule's functionality 97  Can control the time at which a screenshot is taken  Useful for taking a screenshot when the automatically started animation is completed @Test fun test() { composeTestRule.apply { mainClock.autoAdvance = false setContent { ... } mainClock.advanceTimeBy(1_000) // animation playback duration onRoot().captureRoboImage() mainClock.autoAdvance = true } }

Slide 98

Slide 98 text

98 @Test fun test() { composeTestRule.apply { mainClock.autoAdvance = false setContent { ... } mainClock.advanceTimeBy(1_000) // animation playback duration onRoot().captureRoboImage() mainClock.autoAdvance = true } } Declaration to manually control the time governed by composeTestRule Changing config using ComposeTestRule's functionality  Can control the time at which a screenshot is taken  Useful for taking a screenshot when the automatically started animation is completed

Slide 99

Slide 99 text

99 @Test fun test() { composeTestRule.apply { mainClock.autoAdvance = false setContent { ... } mainClock.advanceTimeBy(1_000) // animation playback duration onRoot().captureRoboImage() mainClock.autoAdvance = true } } Advance the clock to the point where you want to take a screenshot Changing config using ComposeTestRule's functionality  Can control the time at which a screenshot is taken  Useful for taking a screenshot when the automatically started animation is completed

Slide 100

Slide 100 text

100 @Test fun test() { composeTestRule.apply { mainClock.autoAdvance = false setContent { ... } mainClock.advanceTimeBy(1_000) // animation playback duration onRoot().captureRoboImage() mainClock.autoAdvance = true } } Restore the clock to automatic mode Changing config using ComposeTestRule's functionality  Can control the time at which a screenshot is taken  Useful for taking a screenshot when the automatically started animation is completed

Slide 101

Slide 101 text

Code Snippet of Composable Preview Scanner 101  Here is how to take a screenshot as indicated in the properties below  Night mode or light mode (uiMode in @Preview)  Screen size (widthDp, heightDp in @Preview)  Font scale (fontScale in @Preview)  Screenshot after a specific time has elapsed  If you want to take a screenshot after a specific time has elapsed, add @DelayedPreview as follows @Preview @DelayedPreview(delay = 2000) @Composable fun PreviewMyAnimationComposable() { ... }

Slide 102

Slide 102 text

ComposePreviewTester 102  Roborazzi provides a ComposePreviewTester interface, which wraps ParameterizedRobolectricTestRunner  We'll implement the interface interface ComposePreviewTester { fun options(): Options = defaultOptionsFromPlugin fun previews(): List> fun test(preview: ComposablePreview) }

Slide 103

Slide 103 text

103 interface ComposePreviewTester { fun options(): Options = defaultOptionsFromPlugin fun previews(): List> fun test(preview: ComposablePreview) } Used to customize options passed from build.gradle.kts You can also specify JUnit4 Rule to inject ComposePreviewTester  Roborazzi provides a ComposePreviewTester interface, which wraps ParameterizedRobolectricTestRunner  We'll implement the interface

Slide 104

Slide 104 text

104 interface ComposePreviewTester { fun options(): Options = defaultOptionsFromPlugin fun previews(): List> fun test(preview: ComposablePreview) } Implement to return a list of preview functions collected by Composable Preview Scanner ComposePreviewTester  Roborazzi provides a ComposePreviewTester interface, which wraps ParameterizedRobolectricTestRunner  We'll implement the interface

Slide 105

Slide 105 text

105 interface ComposePreviewTester { fun options(): Options = defaultOptionsFromPlugin fun previews(): List> fun test(preview: ComposablePreview) } Write test code for each preview function passwd as an argument ComposePreviewTester  Roborazzi provides a ComposePreviewTester interface, which wraps ParameterizedRobolectricTestRunner  We'll implement the interface

Slide 106

Slide 106 text

Note: Caller of ComposePreviewTester 106  Caller of ComposePreviewTester is automatically generated with the name RoborazziPreviewParameterizedTests @RunWith(ParameterizedRobolectricTestRunner::class) class RoborazziPreviewParameterizedTests( private val preview: ComposablePreview ) { // ↓ Instance of the impl class of ComposePreviewTester private val tester = ... ... @Test fun test() { tester.test(preview) } companion object { val previews by lazy { tester.previews() } // lazy for performance @JvmStatic @ParameterizedRobolectricTestRunner.Parameters fun values(): List> = previews } } Excerpts from main part only

Slide 107

Slide 107 text

107 @RunWith(ParameterizedRobolectricTestRunner::class) class RoborazziPreviewParameterizedTests( private val preview: ComposablePreview ) { // ↓ Instance of the impl class of ComposePreviewTester private val tester = ... ... @Test fun test() { tester.test(preview) } companion object { val previews by lazy { tester.previews() } // lazy for performance @JvmStatic @ParameterizedRobolectricTestRunner.Parameters fun values(): List> = previews } } Calling tester.test() for each element of tester.previews() Excerpts from main part only Note: Caller of ComposePreviewTester  Caller of ComposePreviewTester is automatically generated with the name RoborazziPreviewParameterizedTests

Slide 108

Slide 108 text

Implementation of ComposePreviewTester (1/6) 108 class MyPreviewTester: ComposePreviewTester { val composeTestRule = createAndroidComposeRule() override fun options(): ComposePreviewTester.Options { val testLifecycleOptions = ComposePreviewTester.Options.JUnit4TestLifecycleOptions( testRuleFactory = { composeTestRule } ) return super.options().copy( testLifecycleOptions = testLifecycleOptions ) }

Slide 109

Slide 109 text

109 class MyPreviewTester: ComposePreviewTester { val composeTestRule = createAndroidComposeRule() override fun options(): ComposePreviewTester.Options { val testLifecycleOptions = ComposePreviewTester.Options.JUnit4TestLifecycleOptions( testRuleFactory = { composeTestRule } ) return super.options().copy( testLifecycleOptions = testLifecycleOptions ) } Declare composeTestRule (without @get:Rule) Implementation of ComposePreviewTester (1/6)

Slide 110

Slide 110 text

110 class MyPreviewTester: ComposePreviewTester { val composeTestRule = createAndroidComposeRule() override fun options(): ComposePreviewTester.Options { val testLifecycleOptions = ComposePreviewTester.Options.JUnit4TestLifecycleOptions( testRuleFactory = { composeTestRule } ) return super.options().copy( testLifecycleOptions = testLifecycleOptions ) } Specify the JUnit4 Rule to inject (in this case, composeTestRule) Implementation of ComposePreviewTester (1/6)

Slide 111

Slide 111 text

Implementation of ComposePreviewTester (2/6) 111 override fun previews(): List> { val options = options() return AndroidComposablePreviewScanner() .scanPackageTrees(*options.scanOptions.packages.toTypedArray()) .includeAnnotationInfoForAllOf(DelayedPreview::class.java) .getPreviews() }

Slide 112

Slide 112 text

Implementation of ComposePreviewTester (2/6) 112 override fun previews(): List> { val options = options() return AndroidComposablePreviewScanner() .scanPackageTrees(*options.scanOptions.packages.toTypedArray()) .includeAnnotationInfoForAllOf(DelayedPreview::class.java) .getPreviews() } The package passed to be scanned is specified in build.gradle.kts

Slide 113

Slide 113 text

Implementation of ComposePreviewTester (2/6) 113 override fun previews(): List> { val options = options() return AndroidComposablePreviewScanner() .scanPackageTrees(*options.scanOptions.packages.toTypedArray()) .includeAnnotationInfoForAllOf(DelayedPreview::class.java) .getPreviews() } Get @DelayedPreview information as well

Slide 114

Slide 114 text

Implementation of ComposePreviewTester (3/6) 114 override fun test(preview: ComposablePreview) { val delay = preview.getAnnotation()?.delay ?: 0L val defaultFileName = AndroidPreviewScreenshotIdBuilder(preview) .build() val fileName = if (delay == 0L) defaultFileName else "${defaultFileName}_delay$delay" preview.myApplyToRobolectricConfiguration() composeTestRule.activityRule.scenario.recreate() composeTestRule.apply { ... } }

Slide 115

Slide 115 text

Implementation of ComposePreviewTester (3/6) 115 override fun test(preview: ComposablePreview) { val delay = preview.getAnnotation()?.delay ?: 0L val defaultFileName = AndroidPreviewScreenshotIdBuilder(preview) .build() val fileName = if (delay == 0L) defaultFileName else "${defaultFileName}_delay$delay" preview.myApplyToRobolectricConfiguration() composeTestRule.activityRule.scenario.recreate() composeTestRule.apply { ... } } Retrieve the delay property in @DelayedPreview

Slide 116

Slide 116 text

Implementation of ComposePreviewTester (3/6) 116 override fun test(preview: ComposablePreview) { val delay = preview.getAnnotation()?.delay ?: 0L val defaultFileName = AndroidPreviewScreenshotIdBuilder(preview) .build() val fileName = if (delay == 0L) defaultFileName else "${defaultFileName}_delay$delay" preview.myApplyToRobolectricConfiguration() composeTestRule.activityRule.scenario.recreate() composeTestRule.apply { ... } } Construct a unique file name

Slide 117

Slide 117 text

Implementation of ComposePreviewTester (3/6) 117 override fun test(preview: ComposablePreview) { val delay = preview.getAnnotation()?.delay ?: 0L val defaultFileName = AndroidPreviewScreenshotIdBuilder(preview) .build() val fileName = if (delay == 0L) defaultFileName else "${defaultFileName}_delay$delay" preview.myApplyToRobolectricConfiguration() composeTestRule.activityRule.scenario.recreate() composeTestRule.apply { ... } } Using Robolectric API to set up the configuration as indicated by the @Preview properties

Slide 118

Slide 118 text

Implementation of ComposePreviewTester (4/6) 118 composeTestRule.apply { try { if (delay != 0L) { mainClock.autoAdvance = false } setContent { ApplyToCompositionLocal(preview) { preview() } } if (delay != 0L) { mainClock.advanceTimeBy(delay) } onRoot().captureRoboImage(filePath = "$fileName.png") } finally { mainClock.autoAdvance = true } } }

Slide 119

Slide 119 text

Implementation of ComposePreviewTester (4/6) 119 composeTestRule.apply { try { if (delay != 0L) { mainClock.autoAdvance = false } setContent { ApplyToCompositionLocal(preview) { preview() } } if (delay != 0L) { mainClock.advanceTimeBy(delay) } onRoot().captureRoboImage(filePath = "$fileName.png") } finally { mainClock.autoAdvance = true } } } Control the time if delay property is specified

Slide 120

Slide 120 text

Implementation of ComposePreviewTester (4/6) 120 composeTestRule.apply { try { if (delay != 0L) { mainClock.autoAdvance = false } setContent { ApplyToCompositionLocal(preview) { preview() } } if (delay != 0L) { mainClock.advanceTimeBy(delay) } onRoot().captureRoboImage(filePath = "$fileName.png") } finally { mainClock.autoAdvance = true } } } Configure CompositionLocal as indicated by the @Preview properties

Slide 121

Slide 121 text

Implementation of ComposePreviewTester (5/6) 121 fun ComposablePreview.myApplyToRobolectricConfiguration() { val previewInfo = this.previewInfo // ナイトモード when (previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK) { Configuration.UI_MODE_NIGHT_YES -> RuntimeEnvironment.setQualifiers("+night") Configuration.UI_MODE_NIGHT_NO -> RuntimeEnvironment.setQualifiers("+notnight") else -> { /* do nothing */ } } // 画面サイズ if (previewInfo.widthDp != -1 && previewInfo.heightDp != -1) { setDisplaySize(previewInfo.widthDp, previewInfo.heightDp) } }

Slide 122

Slide 122 text

Implementation of ComposePreviewTester (5/6) 122 fun ComposablePreview.myApplyToRobolectricConfiguration() { val previewInfo = this.previewInfo // ナイトモード when (previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK) { Configuration.UI_MODE_NIGHT_YES -> RuntimeEnvironment.setQualifiers("+night") Configuration.UI_MODE_NIGHT_NO -> RuntimeEnvironment.setQualifiers("+notnight") else -> { /* do nothing */ } } // 画面サイズ if (previewInfo.widthDp != -1 && previewInfo.heightDp != -1) { setDisplaySize(previewInfo.widthDp, previewInfo.heightDp) } } Properties in @Preview are stored in previewInfo

Slide 123

Slide 123 text

Implementation of ComposePreviewTester (5/6) 123 fun ComposablePreview.myApplyToRobolectricConfiguration() { val previewInfo = this.previewInfo // ナイトモード when (previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK) { Configuration.UI_MODE_NIGHT_YES -> RuntimeEnvironment.setQualifiers("+night") Configuration.UI_MODE_NIGHT_NO -> RuntimeEnvironment.setQualifiers("+notnight") else -> { /* do nothing */ } } // 画面サイズ if (previewInfo.widthDp != -1 && previewInfo.heightDp != -1) { setDisplaySize(previewInfo.widthDp, previewInfo.heightDp) } } Change configuration by using setQualifiers

Slide 124

Slide 124 text

Implementation of ComposePreviewTester (5/6) 124 fun ComposablePreview.myApplyToRobolectricConfiguration() { val previewInfo = this.previewInfo // ナイトモード when (previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK) { Configuration.UI_MODE_NIGHT_YES -> RuntimeEnvironment.setQualifiers("+night") Configuration.UI_MODE_NIGHT_NO -> RuntimeEnvironment.setQualifiers("+notnight") else -> { /* do nothing */ } } // 画面サイズ if (previewInfo.widthDp != -1 && previewInfo.heightDp != -1) { setDisplaySize(previewInfo.widthDp, previewInfo.heightDp) } } Change screen size by using ShadowDisplay API

Slide 125

Slide 125 text

Implementation of ComposePreviewTester (6/6) 125 @Composable fun ApplyToCompositionLocal( preview: ComposablePreview, content: @Composable () -> Unit ) { val fontScale = preview.previewInfo.fontScale val density = LocalDensity.current val customDensity = Density( density = density.density, fontScale = density.fontScale * fontScale ) CompositionLocalProvider(LocalDensity provides customDensity) { content() } }

Slide 126

Slide 126 text

Implementation of ComposePreviewTester (6/6) 126 @Composable fun ApplyToCompositionLocal( preview: ComposablePreview, content: @Composable () -> Unit ) { val fontScale = preview.previewInfo.fontScale val density = LocalDensity.current val customDensity = Density( density = density.density, fontScale = density.fontScale * fontScale ) CompositionLocalProvider(LocalDensity provides customDensity) { content() } } Override LocalDensity to change font scale

Slide 127

Slide 127 text

Settings of build.gradle.kts 127 LocalDensityを上書きして フォントスケールを変更 roborazzi { ... @OptIn(ExperimentalRoborazziApi::class) generateComposePreviewRobolectricTests { enable = true // The class name of ComposePreviewTester implementation testerQualifiedClassName = "com.example.droidkaigi.MyPreviewTester" packages = listOf("com.example.droidkaigi") } }

Slide 128

Slide 128 text

Summary So Far 128  Basic strategy is to take screenshots with variations as specified in the @Preview properties  There are 3 means of changing the configuration in which screenshots are taken  CompositionLocalProvider  Robolectric  Time control by using ComposeTestRule  Implement ComposePreviewTester interface provided by Roborazzi in case of using Compose Preview Scanner

Slide 129

Slide 129 text

Agenda 129 1. Background (What We Aim to Achieve) 2. Roles of Related Tools 3. Taking Screenshots of Composable Preview Functions 4. Capturing Various Types of Screenshots 5. Differences from Google's Compose Preview Screenshot Testing Methodology 6. Conclusion

Slide 130

Slide 130 text

Google's Compose Preview Screenshot Testing 130  The following features are available by applying the Gradle Plugin provided by Google  Take a screenshot of the preview function placed in src/screenshotTest (not src/main)  The @Preview property is automatically reflected in the screenshot  VRT feature to compare images

Slide 131

Slide 131 text

Compose Preview Screenshot Testing Setup Instructions 131 https://developer.android.com/studio/preview/compose-screenshot-testing  Requirements  Android Gradle Plugin 8.5.0-beta01 or higher  Kotlin 1.9.20 or higher  Compose Compiler 1.5.4 or higher  Versions used for this example  Gradle 8.7  Android Gradle Plugin 8.5.2  Kotlin 1.9.20  Android Compose Screenshot Plugin 0.0.1-alpha05

Slide 132

Slide 132 text

132 https://developer.android.com/studio/preview/compose-screenshot-testing plugins { id("com.android.compose.screenshot") version "0.0.1-alpha05" } android { experimentalProperties["android.experimental.enableScreenshotTest"] = true } dependencies { screenshotTestImplementation("androidx.compose.ui:ui-tooling") }  build.gradle.kts in all modules Compose Preview Screenshot Testing Setup Instructions

Slide 133

Slide 133 text

133 https://developer.android.com/studio/preview/compose-screenshot-testing plugins { id("com.android.compose.screenshot") version "0.0.1-alpha05" } android { experimentalProperties["android.experimental.enableScreenshotTest"] = true } dependencies { screenshotTestImplementation("androidx.compose.ui:ui-tooling") }  build.gradle.kts in all modules It is screenshotTestImplementation not testImplementation Compose Preview Screenshot Testing Setup Instructions

Slide 134

Slide 134 text

134 https://developer.android.com/studio/preview/compose-screenshot-testing android.experimental.enableScreenshotTest=true  gradle.properties  Copy @Preview functions to screenshotTest source set {module}/src/screenshotTest/java/.../ Compose Preview Screenshot Testing Setup Instructions

Slide 135

Slide 135 text

Usage of Compose Preview Screenshot Testing 135  Generate reference images  Only save screenshots and don't create a test report  ./gradlew updateDebugScreenshotTest  Screenshots are saved in {module}/src/{variant}/screenshotTest/reference/  Generate test report  Compare with the reference images and make a test report  ./gradlew validateDebugScreenshotTest  Test report are saved in {module}/build/reports/screenshotTest/preview/{variant}/index.html

Slide 136

Slide 136 text

Difference from using Roborazzi and other methods 136  Usage of Google's tool is very simple  All you have to do is put the preview functions in src/screenshotTest and it will automatically take a screenshot for you  Also support @Preview properties  No test code is needed  Maybe the preview function needs to remain in src/main as well  Sometimes preview is not shown in src/screenshotTest in Android Studio  Not very customizable If your use case is a good fit, you may start with Google's Compose Preview Screenshot Testing

Slide 137

Slide 137 text

Agenda 137 1. Background (What We Aim to Achieve) 2. Roles of Related Tools 3. Taking Screenshots of Composable Preview Functions 4. Capturing Various Types of Screenshots 5. Differences from Google's Compose Preview Screenshot Testing Methodology 6. Conclusion

Slide 138

Slide 138 text

Wrap Up 138  Explained what each of the libraries for taking screenshots can do and their roles  Showed how to combine these libraries to take a screenshot of the preview functions  Further explained how to take screenshots with different variations  As additional information, gave an overview of Google's Compose Preview Screenshot Testing and explained how it differs from the libraries introduced in this session

Slide 139

Slide 139 text

References 139  "Test your Compose layout" https://developer.android.com/develop/ui/compose/testing  Robolectric https://robolectric.org/  Roborazzi https://takahirom.github.io/roborazzi/top.html  Showkase https://github.com/airbnb/Showkase  Composable Preview Scanner https://github.com/sergio-sastre/ComposablePreviewScanner  Google's Compose Preview Screenshot Testing https://developer.android.com/studio/preview/compose-screenshot-testing  "Efficient Testing with Robolectric & Roborazzi Across Many UI States, Devices and Configurations" https://sergiosastre.hashnode.dev/efficient-testing-with-robolectric-roborazzi-across-many-ui- states-devices-and-configurations

Slide 140

Slide 140 text

Finally 140 Please refer to this repository on GitHub for the explanations and the code examples of this session (In Japanese) https://github.com/DeNA/android-modern-architecture-test- handson/blob/main/docs/handson/VisualRegressionTest_Previe w_ComposablePreviewScanner.md 「Composable Preview Scannerを使ってプレビュー画面のス クリーンショットを撮る」

Slide 141

Slide 141 text

141 With this session as a guide, let's leave the tedious work of verifying different screen variations to screenshot testing Thank you for listening!