Skip to content

Instantly share code, notes, and snippets.

@LethalMaus
Created June 4, 2026 19:52
Show Gist options
  • Select an option

  • Save LethalMaus/a4e8330ba3f07419faefe922eb0640f7 to your computer and use it in GitHub Desktop.

Select an option

Save LethalMaus/a4e8330ba3f07419faefe922eb0640f7 to your computer and use it in GitHub Desktop.
Paparazzi preview snapshot test setup
package dev.jamescullimore.app.testing
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.HtmlReportWriter
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.Snapshot
import app.cash.paparazzi.SnapshotHandler
import app.cash.paparazzi.SnapshotVerifier
import app.cash.paparazzi.TestName
import com.android.ide.common.rendering.api.SessionParams
import com.android.resources.Density
import com.android.resources.NightMode
import com.android.resources.ScreenOrientation
import com.android.resources.ScreenRatio
import com.android.resources.ScreenRound
import com.android.resources.ScreenSize
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import com.google.testing.junit.testparameterinjector.TestParameterValuesProvider
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.android.device.DevicePreviewInfoParser
import sergio.sastre.composable.preview.scanner.android.device.domain.Device
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
import kotlin.math.ceil
@RunWith(TestParameterInjector::class)
class PreviewSnapshotTests(
@TestParameter(valuesProvider = PreviewProvider::class)
private val preview: ComposablePreview<AndroidPreviewInfo>,
) {
@get:Rule
val paparazzi: Paparazzi = PreviewSnapshotRule.createFor(preview)
@Test
fun snapshot() {
val info = preview.previewInfo
paparazzi.snapshot(name = info.name) {
PreviewBackground(
showBackground = info.showSystemUi || info.showBackground,
backgroundColor = info.backgroundColor,
) {
if (info.showSystemUi) {
DevicePreviewInfoParser.parse(info.device)?.inDp()?.let { device ->
PreviewSizedToSystemUi(
widthInDp = device.dimensions.width.toInt(),
heightInDp = device.dimensions.height.toInt(),
) {
preview()
}
} ?: preview()
} else {
preview()
}
}
}
}
}
object PreviewProvider : TestParameterValuesProvider() {
override fun provideValues(context: Context?): List<ComposablePreview<AndroidPreviewInfo>> =
AndroidComposablePreviewScanner()
.scanPackageTrees("dev.jamescullimore.app.ui")
.getPreviews()
}
object PreviewSnapshotRule {
fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): Paparazzi {
val info = preview.previewInfo
return Paparazzi(
deviceConfig = DeviceConfigBuilder.build(info),
supportsRtl = true,
showSystemUi = info.showSystemUi,
renderingMode =
when {
info.showSystemUi -> SessionParams.RenderingMode.NORMAL
info.widthDp > 0 && info.heightDp > 0 -> SessionParams.RenderingMode.FULL_EXPAND
info.heightDp > 0 -> SessionParams.RenderingMode.V_SCROLL
else -> SessionParams.RenderingMode.SHRINK
},
snapshotHandler =
when (System.getProperty("paparazzi.test.verify")?.toBoolean() == true) {
true -> PreviewSnapshotVerifier(maxPercentDifference = 0.0)
false -> PreviewHtmlReportWriter()
},
)
}
}
object DeviceConfigBuilder {
fun build(preview: AndroidPreviewInfo): DeviceConfig {
val parsedDevice = DevicePreviewInfoParser.parse(preview.device)?.inPx() ?: return DeviceConfig()
val dimensions = previewDimensions(parsedDevice, preview.widthDp, preview.heightDp)
return DeviceConfig(
screenHeight = dimensions.heightPx,
screenWidth = dimensions.widthPx,
density = Density(parsedDevice.densityDpi),
xdpi = parsedDevice.densityDpi,
ydpi = parsedDevice.densityDpi,
fontScale = preview.fontScale,
size = ScreenSize.valueOf(parsedDevice.screenSize.name),
ratio = ScreenRatio.valueOf(parsedDevice.screenRatio.name),
screenRound = ScreenRound.valueOf(parsedDevice.shape.name),
orientation = ScreenOrientation.valueOf(parsedDevice.orientation.name),
locale = preview.locale.ifBlank { "en" },
nightMode =
when (preview.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) {
true -> NightMode.NIGHT
false -> NightMode.NOTNIGHT
},
)
}
private fun previewDimensions(
parsedDevice: Device,
widthDp: Int,
heightDp: Int,
): PreviewDimensions {
val conversionFactor = parsedDevice.densityDpi / 160f
val widthPx = ceil(widthDp * conversionFactor).toInt()
val heightPx = ceil(heightDp * conversionFactor).toInt()
return PreviewDimensions(
widthPx = if (widthDp > 0) widthPx else parsedDevice.dimensions.width.toInt(),
heightPx = if (heightDp > 0) heightPx else parsedDevice.dimensions.height.toInt(),
)
}
}
data class PreviewDimensions(
val widthPx: Int,
val heightPx: Int,
)
@Composable
private fun PreviewSizedToSystemUi(
widthInDp: Int,
heightInDp: Int,
content: @Composable () -> Unit,
) {
Box(
Modifier
.size(width = widthInDp.dp, height = heightInDp.dp)
.background(Color.White),
) {
content()
}
}
@Composable
private fun PreviewBackground(
showBackground: Boolean,
backgroundColor: Long,
content: @Composable () -> Unit,
) {
if (!showBackground) {
content()
return
}
val color = if (backgroundColor != 0L) Color(backgroundColor) else Color.White
Box(Modifier.background(color)) {
content()
}
}
private class PreviewSnapshotVerifier(
maxPercentDifference: Double,
) : SnapshotHandler {
private val delegate = SnapshotVerifier(maxPercentDifference = maxPercentDifference)
override fun newFrameHandler(
snapshot: Snapshot,
frameCount: Int,
fps: Int,
): SnapshotHandler.FrameHandler {
return delegate.newFrameHandler(
snapshot = snapshot.normalized(),
frameCount = frameCount,
fps = fps,
)
}
override fun close() {
delegate.close()
}
}
private class PreviewHtmlReportWriter : SnapshotHandler {
private val delegate = HtmlReportWriter()
override fun newFrameHandler(
snapshot: Snapshot,
frameCount: Int,
fps: Int,
): SnapshotHandler.FrameHandler {
return delegate.newFrameHandler(
snapshot = snapshot.normalized(),
frameCount = frameCount,
fps = fps,
)
}
override fun close() {
delegate.close()
}
}
private fun Snapshot.normalized(): Snapshot {
val methodName = testName.methodName.substringAfterLast("_").substringBeforeLast("]")
return Snapshot(
name = name,
testName = TestName(packageName = "", className = "", methodName = methodName),
timestamp = timestamp,
tags = tags,
file = file,
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment