Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions Ext/Paparazzi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Testify — Android Screenshot Testing — Paparazzi Extensions

<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>

**Utility library for [Paparazzi](https://github.com/cashapp/paparazzi) snapshot testing, providing factory functions, theme helpers, and multi-variant testing support for Compose UIs.**

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:

- **Device presets** — Curated set of common device configurations (phone, tablet, foldable).
- **Theme helpers** — A `ThemeProvider` interface and extension functions for automatic light/dark snapshot coverage.
- **Factory functions** — `TestifyPaparazzi.component()` and `TestifyPaparazzi.screen()` replace repetitive `Paparazzi(...)` constructors.
- **Font scale testing** — Presets and helpers for verifying accessibility font sizes.
- **Locale/RTL testing** — Presets for internationalization and pseudolocalization testing.
- **Accessibility snapshots** — Pre-configured `AccessibilityRenderExtension` factory.
- **State matrix testing** — Snapshot multiple component states from a single test method.
- **ComposableSnapshotRule** — A high-level JUnit rule combining all features into one declaration.

# Set up testify-paparazzi

**settings.gradle**

Ensure that `mavenCentral()` is available in `dependencyResolutionManagement`.

**Application build.gradle**
```groovy
dependencies {
testImplementation "dev.testify:testify-paparazzi:5.0.1"
testImplementation "app.cash.paparazzi:paparazzi:2.0.0-alpha04"
}
```

# Write a test

### Basic snapshot with theme

Define a `ThemeProvider` for your app's theme:

```kotlin
val myThemeProvider = ThemeProvider { darkTheme, content ->
MyAppTheme(darkTheme = darkTheme) { content() }
}
```

### Using ComposableSnapshotRule

The highest-level API. A single rule declaration provides themed snapshots with no boilerplate:

```kotlin
class MyComponentTest {

@get:Rule val snapshot = ComposableSnapshotRule(themeProvider = myThemeProvider)

@Test fun default() = snapshot.snapshot { MyComponent() }

@Test fun darkTheme() = snapshot.snapshot(variant = ThemeVariant.DARK) { MyComponent() }

@Test fun allThemes() = snapshot.snapshotAllThemes { MyComponent() }
}
```

### Using factory functions directly

For more control, use `TestifyPaparazzi` factory functions with the snapshot extension functions:

```kotlin
class MyComponentTest {

@get:Rule val paparazzi = TestifyPaparazzi.component()

@Test fun default() {
paparazzi.themedSnapshot(myThemeProvider) { MyComponent() }
}

@Test fun allThemes() {
paparazzi.snapshotAllThemes(myThemeProvider) { MyComponent() }
}
}
```

### State matrix testing

Snapshot multiple component states from a single test method:

```kotlin
@Test fun ratingStates() {
paparazzi.snapshotStates(
variants = listOf(
StateVariant("zero_stars", 0),
StateVariant("three_stars", 3),
StateVariant("five_stars", 5),
),
themeProvider = myThemeProvider,
) { rating ->
RatingBar(rating = rating)
}
}
```

### Font scale testing

Verify your UI at different accessibility font sizes:

```kotlin
@Test fun largeFonts() {
paparazzi.snapshotAllFontScales { MyComponent() }
}
```

---

# License

MIT License

Copyright (c) 2026 ndtp

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
64 changes: 64 additions & 0 deletions Ext/Paparazzi/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile

plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
alias(libs.plugins.compose.compiler)
id 'org.jetbrains.dokka'
id 'maven-publish'
id 'signing'
}

ext {
pom = [
publishedGroupId : 'dev.testify',
artifact : 'testify-paparazzi',
libraryName : 'testify-paparazzi',
libraryDescription: 'Paparazzi snapshot testing utilities for Android Testify',
siteUrl : 'https://github.com/ndtp/android-testify',
gitUrl : 'https://github.com/ndtp/android-testify.git',
licenseName : 'The MIT License',
licenseUrl : 'https://opensource.org/licenses/MIT',
author : 'ndtp'
]
}

version = project.findProperty("testify_version") ?: "0.0.1-SNAPSHOT"
group = pom.publishedGroupId
archivesBaseName = pom.artifact

java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}

task sourcesJar(type: Jar) {
archiveClassifier.set('sources')
from sourceSets.main.allSource
}

task javadocJar(type: Jar, dependsOn: dokkaGenerateModuleHtml) {
archiveClassifier.set('javadoc')
from dokkaGenerateModuleHtml.outputs
}

dependencies {
compileOnly libs.paparazzi
compileOnly libs.junit4

compileOnly(platform(libs.androidx.compose.bom))
compileOnly libs.androidx.compose.runtime
compileOnly libs.androidx.ui
}

tasks.withType(KotlinJvmCompile).configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
jvmTarget.set(JvmTarget.JVM_21)
}
}

afterEvaluate {
apply from: "../../publish.build.gradle"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package dev.testify.paparazzi

import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.accessibility.AccessibilityRenderExtension

/**
* Creates a [Paparazzi] instance with [AccessibilityRenderExtension] pre-configured.
*
* The accessibility render extension overlays accessibility metadata (content descriptions,
* roles, and touch target sizes) on top of the rendered snapshot, making it easy to verify
* that composables are properly annotated for screen readers.
*
* @param device The device configuration to use. Defaults to [TestifyPaparazzi.defaultDevice].
* @param theme The Android theme to apply. Defaults to [TestifyPaparazzi.defaultTheme].
* @return A [Paparazzi] instance configured with the accessibility render extension.
*/
fun TestifyPaparazzi.accessibility(
device: DevicePreset = defaultDevice,
theme: String = defaultTheme,
): Paparazzi = Paparazzi(
deviceConfig = device.config,
theme = theme,
renderExtensions = setOf(AccessibilityRenderExtension()),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package dev.testify.paparazzi

import app.cash.paparazzi.Paparazzi
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
import androidx.compose.runtime.Composable
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

/**
* A high-level JUnit [TestRule] that wraps [Paparazzi] and bundles an optional [ThemeProvider].
*
* Combines Paparazzi rule lifecycle management, theme wrapping, and multi-variant snapshot
* helpers into a single rule declaration. This is the highest-level API in the library,
* reducing a typical test to:
*
* ```kotlin
* @get:Rule val snapshot = ComposableSnapshotRule(themeProvider = myThemeProvider)
*
* @Test fun myComponent() = snapshot.snapshot { MyComponent() }
* ```
*
* @param device The [DevicePreset] to render on. Defaults to [DevicePreset.PHONE].
* @param renderingMode The [RenderingMode] for layout sizing. Defaults to [RenderingMode.SHRINK].
* @param theme The Android theme to apply. Defaults to [TestifyPaparazzi.defaultTheme].
* @param themeProvider An optional [ThemeProvider] for wrapping content in the app's Compose theme.
*/
class ComposableSnapshotRule(
device: DevicePreset = DevicePreset.PHONE,
renderingMode: RenderingMode = RenderingMode.SHRINK,
theme: String = TestifyPaparazzi.defaultTheme,
val themeProvider: ThemeProvider? = null,
) : TestRule {

private val paparazzi = Paparazzi(
deviceConfig = device.config,
theme = theme,
renderingMode = renderingMode,
)

override fun apply(base: Statement, description: Description): Statement =
paparazzi.apply(base, description)

/**
* Takes a snapshot of [content], optionally wrapped in the [themeProvider].
*
* If a [themeProvider] was supplied at construction, the content is automatically
* wrapped in the theme for the given [variant]. Otherwise, the content is rendered as-is.
*
* @param name An optional name for the snapshot file.
* @param variant The [ThemeVariant] to apply. Defaults to [ThemeVariant.LIGHT].
* @param content The composable content to snapshot.
*/
fun snapshot(
name: String? = null,
variant: ThemeVariant = ThemeVariant.LIGHT,
content: @Composable () -> Unit,
) {
if (themeProvider != null) {
paparazzi.themedSnapshot(
themeProvider = themeProvider,
variant = variant,
name = name,
content = content,
)
} else {
paparazzi.snapshot(name = name, composable = content)
}
}

/**
* Takes a snapshot of [content] for every [ThemeVariant] (light and dark).
*
* Requires a [themeProvider] to have been set at construction time.
*
* @param name An optional base name prefix for the snapshot files.
* @param content The composable content to snapshot.
* @throws IllegalArgumentException if [themeProvider] is `null`.
*/
fun snapshotAllThemes(
name: String = "",
content: @Composable () -> Unit,
) {
requireNotNull(themeProvider) { "themeProvider must be set to use snapshotAllThemes" }
paparazzi.snapshotAllThemes(
themeProvider = themeProvider,
name = name,
content = content,
)
}

/**
* Takes a snapshot of [content] for each state in [variants].
*
* If a [themeProvider] was supplied at construction, each snapshot is wrapped in the theme.
*
* @param T The type of the state value.
* @param variants The list of [StateVariant] values to iterate over.
* @param content The composable content to snapshot, parameterized by the state value.
*/
fun <T> snapshotStates(
variants: List<StateVariant<T>>,
content: @Composable (T) -> Unit,
) {
paparazzi.snapshotStates(
variants = variants,
themeProvider = themeProvider,
content = content,
)
}
}
Loading