Add preview screenshot testing with Paparazzi and Showkase

Automatic @Preview screenshot testing utilizing Showkase, Paparazzi and TestParameterInjector.

Showkase aggregates all @Preview definitions, and collects them into a list of components
via a KSP processor. We can then use these components as parameterized inputs to tests
configured with TestParameterInjector, and then we use Paparazzi to render each preview
and diff the output.

This CL configures Showkase to run on all UI modules, and then aggregates them all in a test-only
preview-screenshots module. These tests run all previews in a matrix of configurations:
- Each @Preview
- Nexus 5, Pixel 5, Pixel C
- Font scale of 1.0 and 1.5

This could be expanded even further if desired (locale, theming, etc.)

Recording tests can be done with `./gradlew recordPaparazziDemoDebug`, and then checked with
`./gradlew verifyPaparazziDemoDebug`. `check` is configured to depend on `verifyPaparazziDemoDebug`.

Change-Id: I13a64e187517c08a69487caca09c1db52c6e4c98
av/paparazzi-showkase-preview-screenshot-testing
Alex Vanyo 2 years ago
parent fb64a36ea2
commit 6700070b6d

@ -13,11 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
id("nowinandroid.spotless")
alias(libs.plugins.ksp)
}
dependencies {
@ -29,6 +33,9 @@ dependencies {
implementation(libs.coil.kt.compose)
implementation(libs.kotlinx.datetime)
implementation(libs.showkase.runtime)
ksp(libs.showkase.processor)
// TODO : Remove these dependency once we upgrade to Android Studio Dolphin b/228889042
// These dependencies are currently necessary to render Compose previews
debugImplementation(libs.androidx.customview.poolingcontainer)

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.feature")
@ -20,8 +23,12 @@ plugins {
id("nowinandroid.android.library.jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.showkase.runtime)
ksp(libs.showkase.processor)
}

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.feature")
@ -20,10 +23,14 @@ plugins {
id("nowinandroid.android.library.jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.accompanist.flowlayout)
implementation(libs.showkase.runtime)
ksp(libs.showkase.processor)
}

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.feature")
@ -20,4 +23,10 @@ plugins {
id("nowinandroid.android.library.jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.showkase.runtime)
ksp(libs.showkase.processor)
}

@ -131,7 +131,7 @@ private fun InterestsIcon(topicImageUrl: String, modifier: Modifier = Modifier)
@Preview
@Composable
private fun InterestsCardPreview() {
fun InterestsCardPreview() {
NiaTheme {
Surface {
InterestsItem(
@ -148,7 +148,7 @@ private fun InterestsCardPreview() {
@Preview
@Composable
private fun InterestsCardLongNamePreview() {
fun InterestsCardLongNamePreview() {
NiaTheme {
Surface {
InterestsItem(
@ -165,7 +165,7 @@ private fun InterestsCardLongNamePreview() {
@Preview
@Composable
private fun InterestsCardLongDescriptionPreview() {
fun InterestsCardLongDescriptionPreview() {
NiaTheme {
Surface {
InterestsItem(
@ -183,7 +183,7 @@ private fun InterestsCardLongDescriptionPreview() {
@Preview
@Composable
private fun InterestsCardWithEmptyDescriptionPreview() {
fun InterestsCardWithEmptyDescriptionPreview() {
NiaTheme {
Surface {
InterestsItem(

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.feature")
@ -20,8 +23,12 @@ plugins {
id("nowinandroid.android.library.jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.showkase.runtime)
ksp(libs.showkase.processor)
}

@ -40,13 +40,16 @@ ksp = "1.7.10-1.0.6"
ktlint = "0.43.0"
lint = "30.2.2"
okhttp = "4.10.0"
paparazzi = "1.0.0"
protobuf = "3.21.5"
protobufPlugin = "0.8.19"
retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "0.8.0"
room = "2.4.3"
secrets = "2.0.1"
showkase = "1.0.0-beta13"
spotless = "6.7.2"
testParameterInjector = "1.8"
turbine = "0.8.0"
[libraries]
@ -110,12 +113,15 @@ lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref =
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
showkase-runtime = { group = "com.airbnb.android", name = "showkase", version.ref = "showkase" }
showkase-processor = { group = "com.airbnb.android", name = "showkase-processor", version.ref = "showkase" }
testParameterInjector = { group = "com.google.testparameterinjector", name = "test-parameter-injector", version.ref = "testParameterInjector" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
@ -130,6 +136,7 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }

@ -0,0 +1,32 @@
# Preview Screenshot Tests
`preview-screenshots` performs automatic `@Preview` screenshot testing utilizing
[Showkase](https://github.com/airbnb/Showkase),
[Paparazzi](https://github.com/cashapp/paparazzi) and
[TestParameterInjector](https://github.com/google/TestParameterInjector).
Showkase aggregates all `@Preview` definitions, and collects them into a list of components
via a KSP processor. We can then use these components as parameterized inputs to tests
configured with TestParameterInjector, and then we use Paparazzi to render each preview
and diff the output.
This CL configures Showkase to run on all UI modules, and then aggregates them all in a test-only
preview-screenshots module. These tests run all previews in a matrix of configurations:
- Each `@Preview`
- Nexus 5, Pixel 5, Pixel C
- Font scale of `1.0` and `1.5`
This could be expanded even further if desired (locale, theming, etc.)
Screenshots are rendered with `layoutlib`, which is the same tool that drives rendering previews
in Android Studio. As a result, the screenshots being taken do not have the same fidelity as what
will actually displayed on a real device. In particular, certain devices might behave slightly
differently, and it isn't possible to verify display interactions with the system (dialogs,
soft keyboard, etc.)
The advantage, however, is that Paparazzi runs in JVM tests, which means they are faster, and don't
require managing a virtual or physical device, making it easier to parameterize across different
device sizes and configurations.
Recording tests can be done with `./gradlew recordPaparazziDemoDebug`, and then checked with
`./gradlew verifyPaparazziDemoDebug`. `check` is configured to depend on `verifyPaparazziDemoDebug`.

@ -0,0 +1,67 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.Flavor
import com.google.samples.apps.nowinandroid.FlavorDimension
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
id("nowinandroid.spotless")
alias(libs.plugins.ksp)
alias(libs.plugins.paparazzi)
}
androidComponents {
// Disable release builds for this test-only library, no need to run screenshot tests more than
// once
beforeVariants(selector().withBuildType("release")) { builder ->
builder.enable = false
}
// Disable prod builds for this test-only library, no need to run screenshot tests more than
// once
beforeVariants(
selector().withFlavor(FlavorDimension.contentType.name to Flavor.prod.name)
) { builder ->
builder.enable = false
}
}
dependencies {
implementation(project(":core-ui"))
implementation(project(":feature-author"))
implementation(project(":feature-foryou"))
implementation(project(":feature-interests"))
implementation(project(":feature-topic"))
implementation(libs.showkase.runtime)
ksp(libs.showkase.processor)
testImplementation(libs.junit4)
testImplementation(libs.testParameterInjector)
}
tasks.named("check") {
dependsOn("verifyPaparazziDemoDebug")
}
tasks.withType<Test>().configureEach {
// Increase memory for Paparazzi tests
maxHeapSize = "2g"
}

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.preview.screenshots">
</manifest>

@ -0,0 +1,89 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.Density
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import com.airbnb.android.showkase.models.Showkase
import com.airbnb.android.showkase.models.ShowkaseBrowserComponent
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameter.TestParameterValuesProvider
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
class ComponentPreview(
private val showkaseBrowserComponent: ShowkaseBrowserComponent
) {
val content: @Composable () -> Unit = showkaseBrowserComponent.component
override fun toString(): String = showkaseBrowserComponent.componentKey
}
@RunWith(TestParameterInjector::class)
class PreviewScreenshotTests {
object PreviewProvider : TestParameterValuesProvider {
override fun provideValues(): List<ComponentPreview> =
Showkase.getMetadata().componentList.map(::ComponentPreview)
}
enum class BaseDeviceConfig(
val deviceConfig: DeviceConfig,
) {
NEXUS_5(DeviceConfig.NEXUS_5),
PIXEL_5(DeviceConfig.PIXEL_5),
PIXEL_C(DeviceConfig.PIXEL_C),
}
@get:Rule
val paparazzi = Paparazzi(
maxPercentDifference = 0.0,
)
@Test
fun preview_tests(
@TestParameter(valuesProvider = PreviewProvider::class) componentPreview: ComponentPreview,
@TestParameter baseDeviceConfig: BaseDeviceConfig,
@TestParameter(value = ["1.0", "1.5"]) fontScale: Float
) {
paparazzi.unsafeUpdateConfig(
baseDeviceConfig.deviceConfig.copy(
softButtons = false,
)
)
paparazzi.snapshot {
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalDensity provides Density(
density = LocalDensity.current.density,
fontScale = fontScale
)
) {
Box {
componentPreview.content()
}
}
}
}
}

@ -0,0 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid
import com.airbnb.android.showkase.annotation.ShowkaseRoot
import com.airbnb.android.showkase.annotation.ShowkaseRootModule
@ShowkaseRoot
class ScreenshotShowkaseModule : ShowkaseRootModule

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save