August 7, 2026
The Snapshot-Test Matrix, Two Ways: Parameterized JUnit vs TestBalloon
Snapshot testing has a shape, and the shape is a matrix. My app has 27 screen states to capture, each rendered in five configurations —…

By 🇺🇦 Eugen Martynov
3 min read
Snapshot testing has a shape, and the shape is a matrix. My app has 27 screen states to capture, each rendered in five configurations — baseline, longest locale, dark mode, tablet, font scale 2.0 — because dark mode is where hardcoded colours hide, and font scale is where layouts fall apart. That's 135 golden images, and nobody wants to manually declare 135 tests. JUnit annotations can't express this: Robolectric Config annotation takes compile-time constants, and you can't loop over annotations. So the matrix has to be converted to code. Here are two ways to do that, on the same stack — Roborazzi on Robolectric, JVM, no emulator.
The parameterized runner
The established road is the one Sergio Sastre documented: feed the matrix throughParameterizedRobolectricTestRunner and apply configuration at runtime.
@RunWith(ParameterizedRobolectricTestRunner::class)
class CoffeeDrinkSnapshotTest(private val testItem: TestItem) {
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun testData() = /* states × devices × configs as TestItems */
}
@Test
fun snapshot() {
RuntimeEnvironment.setQualifiers(testItem.deviceQualifier)
RuntimeEnvironment.setFontScale(testItem.config.fontScale)
captureRoboImage(testItem.screenshotId) {
CoffeeDrinkListItem(drink = testItem.coffeeDrink.uiState)
}
}
}@RunWith(ParameterizedRobolectricTestRunner::class)
class CoffeeDrinkSnapshotTest(private val testItem: TestItem) {
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun testData() = /* states × devices × configs as TestItems */
}
@Test
fun snapshot() {
RuntimeEnvironment.setQualifiers(testItem.deviceQualifier)
RuntimeEnvironment.setFontScale(testItem.config.fontScale)
captureRoboImage(testItem.screenshotId) {
CoffeeDrinkListItem(drink = testItem.coffeeDrink.uiState)
}
}
}Each matrix cell becomes its own test, and his AndroidUiTestingUtils library rounds the pattern off with a cross-product combinator and a @Rule that applies the config. It's stock JUnit 4, adoptable in any Android codebase this afternoon, and it works.
But notice what the pattern is working around. In JUnit, the unit of everything is the class and its annotations. A test is an annotated method — so without the parameterized runner, every new case means writing yet another @Test function by hand. Reuse across suites means inheritance, because a base class is the only composition tool the model offers. And anything the framework didn't anticipate has to squeeze through the single @RunWith slot and annotation arguments frozen at compile time. The parameterized runner is the escape hatch the model itself provides — the best one available — but you're always negotiating with the framework rather than writing plain code.
The TestBalloon DSL
TestBalloon is a Kotlin Multiplatform test framework where a suite is an expression — so a test per list element is just a loop, and I could shape the same matrix into a small DSL. A screen's whole snapshot coverage is one file:
val MainScreenSnapshotTests by testSuite {
snapshotSuite<MainScreenSnapshotContent>("MainScreen")
}
class MainScreenSnapshotContent(variant: String) :
SnapshotSuiteContent<MainState>(
goldenPrefix = "main_screen",
variantName = variant,
render = { state -> MainScreen(state = state, onAction = {}) },
scenarios = {
scenario("locations_list", MainState(locations = listOf(albertHeijn, jumbo)))
scenario("loading", MainState(isLoading = true))
scenario("error", MainState(error = MainError.Network))
// ... seven more states
},
)val MainScreenSnapshotTests by testSuite {
snapshotSuite<MainScreenSnapshotContent>("MainScreen")
}
class MainScreenSnapshotContent(variant: String) :
SnapshotSuiteContent<MainState>(
goldenPrefix = "main_screen",
variantName = variant,
render = { state -> MainScreen(state = state, onAction = {}) },
scenarios = {
scenario("locations_list", MainState(locations = listOf(albertHeijn, jumbo)))
scenario("loading", MainState(isLoading = true))
scenario("error", MainState(error = MainError.Network))
// ... seven more states
},
)The five variants live in one enum for the whole app, each changing exactly one axis from baseline:
enum class SnapshotVariant(
val qualifiers: String,
val fontScale: Float,
val isDark: Boolean = false
) {
BASELINE(qualifiers = "+en-w411dp-h891dp", fontScale = 1.0f),
DARK(qualifiers = "+en-w411dp-h891dp-night", fontScale = 1.0f, isDark = true),
FONT_SCALE(qualifiers = "+nl-w411dp-h891dp", fontScale = 2.0f),
// LOCALE, TABLET
}enum class SnapshotVariant(
val qualifiers: String,
val fontScale: Float,
val isDark: Boolean = false
) {
BASELINE(qualifiers = "+en-w411dp-h891dp", fontScale = 1.0f),
DARK(qualifiers = "+en-w411dp-h891dp-night", fontScale = 1.0f, isDark = true),
FONT_SCALE(qualifiers = "+nl-w411dp-h891dp", fontScale = 2.0f),
// LOCALE, TABLET
}A ten-line snapshotSuite function multiplies them: for each variant it registers a Robolectric suite whose sandbox is configured up front — SDK, qualifiers, font scale — through TestBalloon's TestConfig.robolectric, no setQualifiers mutation inside a running test. Inside, a JUnit4RulesContext fixture still wraps createComposeRule(), so the Compose Rule survives untouched. The report comes out as a tree — MainScreen snapshots (dark) → error — rerunnable and filterable per node, instead of 135 flat concatenated names. And where the parameterized runner has to smuggle the whole matrix across Robolectric's sandbox classloader (which is why Sergio's parameters are carefully enums and strings), here exactly one String crosses: the variant's name.
The complete working machinery — the full variant enum, the suite builder with its Robolectric config, and a whole example screen — is in this ✨gist.
I'll admit the sentiment plainly: I like how elegant this reads. And because a suite is plain Kotlin all the way down, the DSL is mine to bend. If tomorrow I want a golden for every error type the screen can show, an ordinary loop inside the scenarios block does it
MainError.entries.forEach {
scenario("error_${it.name.lowercase()}", MainState(error = it))
}MainError.entries.forEach {
scenario("error_${it.name.lowercase()}", MainState(error = it))
}no framework bend, no inheritance, and pure composition.
That's my gut feeling after living with both models: with the DSL I customise, with JUnit I negotiate. Credit where due, though — it was Sergio's parameterize-over-state framing that pushed my DSL to its current form.
The final pit
One sharp edge in particular: the current public release of TestBalloon cannot run this Compose-on-Android setup at all — Android test targets hit a missing-entry-point bug (issue #84). An experimental branch fixes it, and my project runs a snapshot build pinned from that branch until the fix lands in a release.
My app is a small Compose Multiplatform project.
TestBalloon: github.com/infix-de/testBalloon
Roborazzi: github.com/takahirom/roborazzi