Ackee Android Snapshots is an opinionated framework designed for snapshot testing in Android projects, particularly focusing on Jetpack Compose UI components. It leverages Paparazzi for rendering snapshots, Showkase for component discovery, and Kotest as the testing framework.
You tag each Compose preview with a snapshot kind, then describe the whole snapshot matrix — kinds, devices, UI modes and font scales — in a single config DSL on one test class. The framework expands that config (plus any per-preview exceptions) into the exact set of golden images and renders each one through the engine. A single test class can therefore span many devices, UI modes and font scales at once.
The framework is designed with extensibility in mind, using a modular architecture:
annotationsmodule contains the preview tags read from@ShowkaseComposableextraMetadata(PreviewSnapshotKindplus the per-preview override tokensPreviewFontScale/PreviewUiMode/PreviewDevice). Used in the production source set (to tag previews) and on the test classpath.frameworkmodule defines the config DSL, the resolver that turns config + previews into the exact set of snapshot variants, theSnapshotEngineinterface and theAckeeSnapshotTestsKotest base. Depends onannotations.paparazzimodule provides theSnapshotEngineimplementation backed by Paparazzi (PaparazziSnapshotTests).- Additional snapshot engine implementations can be added by implementing the
SnapshotEngineinterface.
- Automated snapshot testing for Jetpack Compose components
- One test class spans many variants — no more one class per device/orientation/theme/strategy combination
- Component and full-screen rendering kinds
- Support for different device configurations (Phone/Tablet) and orientations (Portrait/Landscape)
- Dark/Light UI mode testing
- Font scale testing
- Per-preview exceptions without touching the library: inline
extraMetadatatokens and namedprofiles (narrow or widen a single axis for one preview), plus a standingexcludepredicate for axis couplings - Fail-fast validation: untagged, over-restricted or name-colliding previews throw
SnapshotConfigExceptionat test-class construction - Integration with popular testing tools:
- Paparazzi for snapshot generation
- Showkase for component discovery
- Kotest for test execution
Add the following configuration, depending on what you need. You should always use BOM to be sure to
get binary compatible dependencies. If you have a custom snapshots engine, you will need only
annotations and framework dependencies. Additionally, you can add paparazzi as well for our
Paparazzi engine implementation.
For annotations and framework artifacts you will need the following configuration:
[versions]
ackee-snapshots-bom = "SPECIFY_VERSION"
showkase = "SPECIFY_VERSION"
[dependencies]
ackee-snapshots-bom = { group = "io.github.ackeecz", name = "snapshots-bom", version.ref = "ackee-snapshots-bom" }
ackee-snapshots-annotations = { group = "io.github.ackeecz", name = "snapshots-annotations" }
ackee-snapshots-framework = { group = "io.github.ackeecz", name = "snapshots-framework" }
# Showkase is needed for annotating previews and passing a selected PreviewSnapshotKind
showkase-core = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase-processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }and specify dependencies
implementation(platform(libs.ackee.snapshots.bom))
implementation(libs.ackee.snapshots.annotations)
testImplementation(libs.ackee.snapshots.framework)
implementation(libs.showkase.core)
ksp(libs.showkase.processor)
frameworkdepends onannotations, andpaparazzidepends onframework. The BOM pins a compatible set of all three, so you never specify their versions explicitly.
If you use paparazzi artifact, you will need the following additional configuration:
[versions]
# Ensure that this version is equal or greater than Paparazzi version in this project. Otherwise
# there might be incompatibility between Paparazzi runtime used in Ackee Snapshots and Paparazzi
# Gradle plugin used by your app.
paparazzi = "SPECIFY_VERSION"
[dependencies]
ackee-snapshots-paparazzi = { group = "io.github.ackeecz", name = "snapshots-paparazzi" }
[plugins]
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }Apply the Paparazzi Gradle plugin in your module's build.gradle.kts:
plugins {
id(libs.plugins.paparazzi)
}and specify dependencies
testImplementation(libs.ackee.snapshots.paparazzi)The config DSL (the PaparazziSnapshotTests { … } block and everything inside it) and the
SnapshotEngine extension point are annotated @ExperimentalSnapshotsApi. The library is stable and
well-tested, but this surface may still change as it is exercised against more, and more complex,
projects — marking it experimental lets us refine the API without an immediate breaking release.
You must opt in. Either per test class / file:
import io.github.ackeecz.snapshots.annotations.ExperimentalSnapshotsApi
@OptIn(ExperimentalSnapshotsApi::class)
class SampleSnapshotTests : PaparazziSnapshotTests({ /* … */ })…or once per Gradle module that holds snapshot tests:
// build.gradle.kts
kotlin {
compilerOptions {
optIn.add("io.github.ackeecz.snapshots.annotations.ExperimentalSnapshotsApi")
}
}ExperimentalSnapshotsApi (opt-in level WARNING) ships in the snapshots-annotations artifact. For
brevity, the remaining snippets in this README omit the annotation.
Every preview you want to snapshot is a @ShowkaseComposable whose extraMetadata carries exactly one snapshot-kind tag from PreviewSnapshotKind:
PreviewSnapshotKind.Component— rendered device-independent, at the minimal size that fits the content. Ideal for individual UI components.PreviewSnapshotKind.Screen— rendered within the full frame of each configured device (device + orientation). Perfect for complete screens.
Showkase collects both @Preview and @ShowkaseComposable functions, but only the latter carries extraMetadata. Use @Preview to render the
composable in Android Studio and @ShowkaseComposable to opt it into snapshots and choose its kind:
@Preview
@ShowkaseComposable(extraMetadata = [PreviewSnapshotKind.Component])
@Composable
fun PrimaryButtonPreview() {
SnapshotsSampleTheme {
PrimaryButton(text = "Click me") { }
}
}
@Preview
@ShowkaseComposable(extraMetadata = [PreviewSnapshotKind.Screen])
@Composable
fun HomeScreenPreview() {
SnapshotsSampleTheme {
HomeScreen()
}
}Every preview passed to
previews(...)must carry aPreviewSnapshotKindtag — an untagged preview fails test-class construction with aSnapshotConfigException. If your Showkase metadata also contains previews you don't want to snapshot (e.g.@Preview-only functions), filter them out before passing them in (see Scoping previews).
Subclass PaparazziSnapshotTests and describe the snapshot matrix in the config block. A single class can list both kinds and every axis:
@OptIn(ExperimentalSnapshotsApi::class) // the config DSL is experimental — see "Experimental API"
class SampleSnapshotTests : PaparazziSnapshotTests({
// Required: the previews to snapshot. Every one must carry a PreviewSnapshotKind tag.
previews(Showkase.getMetadata())
// Required: wraps every rendered preview — typically your app theme, keyed off the UI mode.
decorate { uiMode, content ->
SnapshotsSampleTheme(darkTheme = uiMode == UiMode.DARK, dynamicColor = false) {
content()
}
}
// Optional: runs before each snapshot with the prepared rendering Context (default: no-op).
// before { context -> /* seed a fake, set a locale, … */ }
// Required: the variant matrix.
variants {
components() // snapshot Component-tagged previews
screens(Device.Pixel6.portrait, Device.Nexus10.landscape) // snapshot Screen-tagged previews on these devices
uiModes(UiMode.LIGHT, UiMode.DARK) // at least one required
fontScales(FontScale.NORMAL, FontScale.LARGE) // at least one required
}
})For each preview, the framework renders the Cartesian product of the enabled kind(s), the applicable devices (screens only), uiModes and
fontScales, minus any excluded cells and after applying per-preview overrides. The example above snapshots every component in
{LIGHT, DARK} × {NORMAL, LARGE} and every screen additionally across the two devices.
The DSL surface:
| Call | Where | Required | Purpose |
|---|---|---|---|
previews(metadata) |
config | yes | The Showkase previews to snapshot (pre-filter to scope). |
decorate { uiMode, content -> } |
config | yes | Wrapper around every preview (your theme), keyed off the UiMode. |
before { context -> } |
config | no | Setup before each snapshot, given the prepared Context. |
variants { } |
config | yes | Declares the matrix. |
components() |
variants | — | Enable Component-tagged previews. |
screens(vararg DeviceConfig) |
variants | — | Enable Screen-tagged previews on the given devices. |
uiModes(vararg UiMode) |
variants | yes (≥1) | UI modes every preview is rendered in. |
fontScales(vararg FontScale) |
variants | yes (≥1) | Font scales every preview is rendered at. |
exclude { variant -> Boolean } |
variants | no (0..n) | Drop every matching SnapshotVariant from the matrix. |
profile(key) { } |
variants | no (0..n) | Define a named per-preview override (see below). |
Ready-made device shortcuts live under the Device enum: Device.Pixel6.portrait, Device.Pixel6.landscape, Device.Nexus10.portrait,
Device.Nexus10.landscape — each a DeviceConfig.
exclude removes cells from the Cartesian product and is re-applied after overrides, so axis couplings always hold. For example, to skip the
DARK × LARGE combination (you already cover LARGE in light mode and DARK in normal) while still covering both axes:
variants {
components()
screens(Device.Pixel6.portrait)
uiModes(UiMode.LIGHT, UiMode.DARK)
fontScales(FontScale.NORMAL, FontScale.LARGE)
exclude { it.uiMode == UiMode.DARK && it.fontScale == FontScale.LARGE }
}Sometimes a single preview needs a different set of axes than the rest of the class (a text-less component doesn't need font-scale variants; one screen
should only be captured on a phone). Overrides let that exception live at the preview, without a new test class. Each axis resolves independently as
inline token ?: profile ?: class, then exclude is re-applied. Overrides may narrow the class axes or widen them beyond what the class
declared (as long as exclude doesn't remove the new cell); a preview that resolves to zero variants is an error.
Inline tokens — tag the preview with an extraMetadata token to pin one axis for that preview only:
@Preview
@ShowkaseComposable(
// This component is captured only at NORMAL font scale, in LIGHT mode — regardless of the class axes.
extraMetadata = [PreviewSnapshotKind.Component, PreviewFontScale.Normal, PreviewUiMode.Light],
)
@Composable
fun StatusBadgePreview() { StatusBadge(text = "Active") }
@Preview
@ShowkaseComposable(
// This screen is captured only on Pixel 6 portrait.
extraMetadata = [PreviewSnapshotKind.Screen, PreviewDevice.Pixel6Portrait],
)
@Composable
fun LoginScreenPreview() { LoginScreen() }Available tokens (all in the annotations module): PreviewFontScale.{Small, Normal, Large}, PreviewUiMode.{Light, Dark},
PreviewDevice.{Pixel6Portrait, Pixel6Landscape, Nexus10Portrait, Nexus10Landscape}. Tagging multiple values of the same axis sets that axis to the
set (e.g. two PreviewFontScale tokens ⇒ both scales).
Named profiles — when several previews share the same exception, define it once as a profile(key) and reference the key from each preview. Keep
the key in a shared const so the tag and the profile can't drift apart:
object PreviewProfile {
const val NoText = "noText"
}
// preview:
@Preview
@ShowkaseComposable(extraMetadata = [PreviewSnapshotKind.Component, PreviewProfile.NoText])
@Composable
fun AvatarPreview() { Avatar() }
// test class:
class SampleSnapshotTests : PaparazziSnapshotTests({
previews(Showkase.getMetadata())
decorate { uiMode, content -> SnapshotsSampleTheme(darkTheme = uiMode == UiMode.DARK) { content() } }
variants {
components()
uiModes(UiMode.LIGHT, UiMode.DARK)
fontScales(FontScale.NORMAL, FontScale.LARGE)
// The text-less Avatar doesn't need font-scale variants:
profile(PreviewProfile.NoText) { fontScales(FontScale.NORMAL) }
}
})A profile body (ProfileOverrideScope) may override any subset of devices(...), uiModes(...), fontScales(...); axes it leaves unset fall through
to the class config. A preview may reference at most one profile, but may still add inline tokens on top of it — an inline token wins over the
profile on the same axis.
previews(...) takes a ShowkaseElementsMetadata. Pass the whole set (Showkase.getMetadata()) only if every discovered preview carries a
PreviewSnapshotKind tag; otherwise pre-filter. This is also how you scope a shared base class to one module or environment:
val snapshotPreviews = ShowkaseElementsMetadata(
componentList = Showkase.getMetadata().componentList.filter {
it.extraMetadata.contains(PreviewSnapshotKind.Component) ||
it.extraMetadata.contains(PreviewSnapshotKind.Screen)
},
)Paparazzi is a JUnit4 rule, so a single Paparazzi instance renders one fixed device configuration. The framework works around this for you: it groups
the resolved variants by (kind, device, UI mode) and opens a Kotest context per group, each backed by a fresh engine instance configured for
that group. You therefore no longer need a separate test class per device/orientation/theme — one class covers them all, and each group renders with
its own correctly-configured Paparazzi.
Golden file names encode every axis so no two variants ever collide:
<id>_<device>_<uiMode>_FontScale-<fontScale>
<id> is the preview's group_componentName; the <device> segment (e.g. Pixel6-portrait) is present for screens and omitted for
device-independent components. For example: ArticleCard_LIGHT_FontScale-NORMAL (component) and SettingsScreen_Pixel6-portrait_DARK_FontScale-LARGE
(screen).
Earlier versions took a fixed positional constructor (one strategy, one uiTheme, a fontScales list, a pre-filtered preview list, a before and a
theme), so you wrote one class per device/orientation/theme/strategy combination. The config DSL collapses those into a single class. Renames to know:
UiTheme → UiMode, theme → decorate, the SnapshotStrategy sealed type → the components() / screens(...) DSL calls plus a separate device
axis, and PreviewSnapshotStrategy → PreviewSnapshotKind. Because the UI mode is now part of every golden name, re-record all goldens after
upgrading.
(a) Light + dark with a theme→font-scale asymmetry → one class using exclude. The old pattern rendered light in {NORMAL, LARGE} but dark only in
{NORMAL} across two classes. Now:
class ComponentsSnapshotTests : PaparazziSnapshotTests({
previews(snapshotPreviews)
decorate { uiMode, content -> AppTheme(dark = uiMode == UiMode.DARK) { content() } }
variants {
components()
uiModes(UiMode.LIGHT, UiMode.DARK)
fontScales(FontScale.NORMAL, FontScale.LARGE)
exclude { it.uiMode == UiMode.DARK && it.fontScale == FontScale.LARGE }
}
})(b) Multi-module project scoping previews to its own module → one shared base + thin per-module subclasses. Put the shared config in a base class
that takes the module's metadata; each module supplies a pre-filtered ShowkaseElementsMetadata:
abstract class BaseSnapshotTests(previews: ShowkaseElementsMetadata) : PaparazziSnapshotTests({
previews(previews)
decorate { uiMode, content -> AppTheme(dark = uiMode == UiMode.DARK) { content() } }
variants {
components()
screens(Device.Pixel6.portrait)
uiModes(UiMode.LIGHT, UiMode.DARK)
fontScales(FontScale.NORMAL)
}
})
class FeatureProfileSnapshotTests : BaseSnapshotTests(profileModulePreviews)(c) Components and screens sharing setup → one class listing both kinds:
class UiSnapshotTests : PaparazziSnapshotTests({
previews(snapshotPreviews)
decorate { uiMode, content -> AppTheme(dark = uiMode == UiMode.DARK) { content() } }
variants {
components()
screens(Device.Pixel6.portrait, Device.Nexus10.landscape)
uiModes(UiMode.LIGHT, UiMode.DARK)
fontScales(FontScale.NORMAL, FontScale.LARGE)
}
})(d) Per-preview font-scale opt-out → a noText profile. Instead of a separate class (or a copied test-generation loop) for the handful of previews
that shouldn't vary by font scale, tag them and add a one-line profile — see Named profiles above.
To generate screenshots, run the Paparazzi record task:
./gradlew cleanRecordPaparazziDebugThis will generate screenshots for all annotated composables in your source set. The snapshots are stored in:
{module}/src/test/snapshots
To verify that your UI components haven't changed unexpectedly, run:
./gradlew verifyPaparazziDebugIf there are any differences between the recorded and current snapshots, the test will fail and Paparazzi will generate a report showing the differences.
- bom: BOM module
- annotations: Preview tags and per-preview override tokens. Used in test and production source sets.
- framework: Config DSL, resolver,
SnapshotEngineinterface and Kotest test-generation infrastructure - paparazzi: Paparazzi integration for snapshot generation
- sample: Example application demonstrating usage (see the per-concern
*SnapshotTestsclasses undersample/src/test)
Contributions are welcome! Please feel free to submit a Pull Request.
Developed by Ackee team with 💙.
