Skip to content

Commit a7d2a4a

Browse files
committed
Add testify-paparazzi utility library (Ext/Paparazzi)
Introduces a new JVM library module providing Paparazzi snapshot testing utilities: device presets, theme helpers, font scale/locale testing, accessibility snapshot support, state matrix testing, and a high-level ComposableSnapshotRule. Includes CI verification in bitrise.yml.
1 parent 396a59a commit a7d2a4a

14 files changed

Lines changed: 1010 additions & 0 deletions

Ext/Paparazzi/README.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Testify — Android Screenshot Testing — Paparazzi Extensions
2+
3+
<a href="https://search.maven.org/artifact/dev.testify/testify-paparazzi"><img alt="Maven Central" src="https://img.shields.io/maven-central/v/dev.testify/testify-paparazzi?color=%236e40ed&label=dev.testify%3Atestify-paparazzi"/></a>
4+
5+
**Utility library for [Paparazzi](https://github.com/cashapp/paparazzi) snapshot testing, providing factory functions, theme helpers, and multi-variant testing support for Compose UIs.**
6+
7+
Paparazzi snapshot tests often repeat identical boilerplate: rule construction, theme wrapping, and manual light/dark duplication. The Testify Paparazzi extension eliminates this repetition by providing:
8+
9+
- **Device presets** — Curated set of common device configurations (phone, tablet, foldable).
10+
- **Theme helpers** — A `ThemeProvider` interface and extension functions for automatic light/dark snapshot coverage.
11+
- **Factory functions**`TestifyPaparazzi.component()` and `TestifyPaparazzi.screen()` replace repetitive `Paparazzi(...)` constructors.
12+
- **Font scale testing** — Presets and helpers for verifying accessibility font sizes.
13+
- **Locale/RTL testing** — Presets for internationalization and pseudolocalization testing.
14+
- **Accessibility snapshots** — Pre-configured `AccessibilityRenderExtension` factory.
15+
- **State matrix testing** — Snapshot multiple component states from a single test method.
16+
- **ComposableSnapshotRule** — A high-level JUnit rule combining all features into one declaration.
17+
18+
# Set up testify-paparazzi
19+
20+
**settings.gradle**
21+
22+
Ensure that `mavenCentral()` is available in `dependencyResolutionManagement`.
23+
24+
**Application build.gradle**
25+
```groovy
26+
dependencies {
27+
testImplementation "dev.testify:testify-paparazzi:5.0.1"
28+
testImplementation "app.cash.paparazzi:paparazzi:2.0.0-alpha04"
29+
}
30+
```
31+
32+
# Write a test
33+
34+
### Basic snapshot with theme
35+
36+
Define a `ThemeProvider` for your app's theme:
37+
38+
```kotlin
39+
val myThemeProvider = ThemeProvider { darkTheme, content ->
40+
MyAppTheme(darkTheme = darkTheme) { content() }
41+
}
42+
```
43+
44+
### Using ComposableSnapshotRule
45+
46+
The highest-level API. A single rule declaration provides themed snapshots with no boilerplate:
47+
48+
```kotlin
49+
class MyComponentTest {
50+
51+
@get:Rule val snapshot = ComposableSnapshotRule(themeProvider = myThemeProvider)
52+
53+
@Test fun default() = snapshot.snapshot { MyComponent() }
54+
55+
@Test fun darkTheme() = snapshot.snapshot(variant = ThemeVariant.DARK) { MyComponent() }
56+
57+
@Test fun allThemes() = snapshot.snapshotAllThemes { MyComponent() }
58+
}
59+
```
60+
61+
### Using factory functions directly
62+
63+
For more control, use `TestifyPaparazzi` factory functions with the snapshot extension functions:
64+
65+
```kotlin
66+
class MyComponentTest {
67+
68+
@get:Rule val paparazzi = TestifyPaparazzi.component()
69+
70+
@Test fun default() {
71+
paparazzi.themedSnapshot(myThemeProvider) { MyComponent() }
72+
}
73+
74+
@Test fun allThemes() {
75+
paparazzi.snapshotAllThemes(myThemeProvider) { MyComponent() }
76+
}
77+
}
78+
```
79+
80+
### State matrix testing
81+
82+
Snapshot multiple component states from a single test method:
83+
84+
```kotlin
85+
@Test fun ratingStates() {
86+
paparazzi.snapshotStates(
87+
variants = listOf(
88+
StateVariant("zero_stars", 0),
89+
StateVariant("three_stars", 3),
90+
StateVariant("five_stars", 5),
91+
),
92+
themeProvider = myThemeProvider,
93+
) { rating ->
94+
RatingBar(rating = rating)
95+
}
96+
}
97+
```
98+
99+
### Font scale testing
100+
101+
Verify your UI at different accessibility font sizes:
102+
103+
```kotlin
104+
@Test fun largeFonts() {
105+
paparazzi.snapshotAllFontScales { MyComponent() }
106+
}
107+
```
108+
109+
---
110+
111+
# License
112+
113+
MIT License
114+
115+
Copyright (c) 2026 ndtp
116+
117+
Permission is hereby granted, free of charge, to any person obtaining a copy
118+
of this software and associated documentation files (the "Software"), to deal
119+
in the Software without restriction, including without limitation the rights
120+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
121+
copies of the Software, and to permit persons to whom the Software is
122+
furnished to do so, subject to the following conditions:
123+
124+
The above copyright notice and this permission notice shall be included in all
125+
copies or substantial portions of the Software.
126+
127+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
128+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
129+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
130+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
131+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
132+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
133+
SOFTWARE.

Ext/Paparazzi/build.gradle

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
3+
4+
plugins {
5+
id 'java-library'
6+
id 'org.jetbrains.kotlin.jvm'
7+
alias(libs.plugins.compose.compiler)
8+
id 'org.jetbrains.dokka'
9+
id 'maven-publish'
10+
id 'signing'
11+
}
12+
13+
ext {
14+
pom = [
15+
publishedGroupId : 'dev.testify',
16+
artifact : 'testify-paparazzi',
17+
libraryName : 'testify-paparazzi',
18+
libraryDescription: 'Paparazzi snapshot testing utilities for Android Testify',
19+
siteUrl : 'https://github.com/ndtp/android-testify',
20+
gitUrl : 'https://github.com/ndtp/android-testify.git',
21+
licenseName : 'The MIT License',
22+
licenseUrl : 'https://opensource.org/licenses/MIT',
23+
author : 'ndtp'
24+
]
25+
}
26+
27+
version = project.findProperty("testify_version") ?: "0.0.1-SNAPSHOT"
28+
group = pom.publishedGroupId
29+
archivesBaseName = pom.artifact
30+
31+
java {
32+
sourceCompatibility = JavaVersion.VERSION_21
33+
targetCompatibility = JavaVersion.VERSION_21
34+
}
35+
36+
task sourcesJar(type: Jar) {
37+
archiveClassifier.set('sources')
38+
from sourceSets.main.allSource
39+
}
40+
41+
task javadocJar(type: Jar, dependsOn: dokkaGenerateModuleHtml) {
42+
archiveClassifier.set('javadoc')
43+
from dokkaGenerateModuleHtml.outputs
44+
}
45+
46+
dependencies {
47+
compileOnly libs.paparazzi
48+
compileOnly libs.junit4
49+
50+
compileOnly(platform(libs.androidx.compose.bom))
51+
compileOnly libs.androidx.compose.runtime
52+
compileOnly libs.androidx.ui
53+
}
54+
55+
tasks.withType(KotlinJvmCompile).configureEach {
56+
compilerOptions {
57+
allWarningsAsErrors.set(true)
58+
jvmTarget.set(JvmTarget.JVM_21)
59+
}
60+
}
61+
62+
afterEvaluate {
63+
apply from: "../../publish.build.gradle"
64+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2026 ndtp
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package dev.testify.paparazzi
26+
27+
import app.cash.paparazzi.Paparazzi
28+
import app.cash.paparazzi.accessibility.AccessibilityRenderExtension
29+
30+
/**
31+
* Creates a [Paparazzi] instance with [AccessibilityRenderExtension] pre-configured.
32+
*
33+
* The accessibility render extension overlays accessibility metadata (content descriptions,
34+
* roles, and touch target sizes) on top of the rendered snapshot, making it easy to verify
35+
* that composables are properly annotated for screen readers.
36+
*
37+
* @param device The device configuration to use. Defaults to [TestifyPaparazzi.defaultDevice].
38+
* @param theme The Android theme to apply. Defaults to [TestifyPaparazzi.defaultTheme].
39+
* @return A [Paparazzi] instance configured with the accessibility render extension.
40+
*/
41+
fun TestifyPaparazzi.accessibility(
42+
device: DevicePreset = defaultDevice,
43+
theme: String = defaultTheme,
44+
): Paparazzi = Paparazzi(
45+
deviceConfig = device.config,
46+
theme = theme,
47+
renderExtensions = setOf(AccessibilityRenderExtension()),
48+
)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2026 ndtp
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package dev.testify.paparazzi
26+
27+
import app.cash.paparazzi.Paparazzi
28+
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
29+
import androidx.compose.runtime.Composable
30+
import org.junit.rules.TestRule
31+
import org.junit.runner.Description
32+
import org.junit.runners.model.Statement
33+
34+
/**
35+
* A high-level JUnit [TestRule] that wraps [Paparazzi] and bundles an optional [ThemeProvider].
36+
*
37+
* Combines Paparazzi rule lifecycle management, theme wrapping, and multi-variant snapshot
38+
* helpers into a single rule declaration. This is the highest-level API in the library,
39+
* reducing a typical test to:
40+
*
41+
* ```kotlin
42+
* @get:Rule val snapshot = ComposableSnapshotRule(themeProvider = myThemeProvider)
43+
*
44+
* @Test fun myComponent() = snapshot.snapshot { MyComponent() }
45+
* ```
46+
*
47+
* @param device The [DevicePreset] to render on. Defaults to [DevicePreset.PHONE].
48+
* @param renderingMode The [RenderingMode] for layout sizing. Defaults to [RenderingMode.SHRINK].
49+
* @param theme The Android theme to apply. Defaults to [TestifyPaparazzi.defaultTheme].
50+
* @param themeProvider An optional [ThemeProvider] for wrapping content in the app's Compose theme.
51+
*/
52+
class ComposableSnapshotRule(
53+
device: DevicePreset = DevicePreset.PHONE,
54+
renderingMode: RenderingMode = RenderingMode.SHRINK,
55+
theme: String = TestifyPaparazzi.defaultTheme,
56+
val themeProvider: ThemeProvider? = null,
57+
) : TestRule {
58+
59+
private val paparazzi = Paparazzi(
60+
deviceConfig = device.config,
61+
theme = theme,
62+
renderingMode = renderingMode,
63+
)
64+
65+
override fun apply(base: Statement, description: Description): Statement =
66+
paparazzi.apply(base, description)
67+
68+
/**
69+
* Takes a snapshot of [content], optionally wrapped in the [themeProvider].
70+
*
71+
* If a [themeProvider] was supplied at construction, the content is automatically
72+
* wrapped in the theme for the given [variant]. Otherwise, the content is rendered as-is.
73+
*
74+
* @param name An optional name for the snapshot file.
75+
* @param variant The [ThemeVariant] to apply. Defaults to [ThemeVariant.LIGHT].
76+
* @param content The composable content to snapshot.
77+
*/
78+
fun snapshot(
79+
name: String? = null,
80+
variant: ThemeVariant = ThemeVariant.LIGHT,
81+
content: @Composable () -> Unit,
82+
) {
83+
if (themeProvider != null) {
84+
paparazzi.themedSnapshot(
85+
themeProvider = themeProvider,
86+
variant = variant,
87+
name = name,
88+
content = content,
89+
)
90+
} else {
91+
paparazzi.snapshot(name = name, composable = content)
92+
}
93+
}
94+
95+
/**
96+
* Takes a snapshot of [content] for every [ThemeVariant] (light and dark).
97+
*
98+
* Requires a [themeProvider] to have been set at construction time.
99+
*
100+
* @param name An optional base name prefix for the snapshot files.
101+
* @param content The composable content to snapshot.
102+
* @throws IllegalArgumentException if [themeProvider] is `null`.
103+
*/
104+
fun snapshotAllThemes(
105+
name: String = "",
106+
content: @Composable () -> Unit,
107+
) {
108+
requireNotNull(themeProvider) { "themeProvider must be set to use snapshotAllThemes" }
109+
paparazzi.snapshotAllThemes(
110+
themeProvider = themeProvider,
111+
name = name,
112+
content = content,
113+
)
114+
}
115+
116+
/**
117+
* Takes a snapshot of [content] for each state in [variants].
118+
*
119+
* If a [themeProvider] was supplied at construction, each snapshot is wrapped in the theme.
120+
*
121+
* @param T The type of the state value.
122+
* @param variants The list of [StateVariant] values to iterate over.
123+
* @param content The composable content to snapshot, parameterized by the state value.
124+
*/
125+
fun <T> snapshotStates(
126+
variants: List<StateVariant<T>>,
127+
content: @Composable (T) -> Unit,
128+
) {
129+
paparazzi.snapshotStates(
130+
variants = variants,
131+
themeProvider = themeProvider,
132+
content = content,
133+
)
134+
}
135+
}

0 commit comments

Comments
 (0)