A user opened an issue against FormaUI: the bar chart rendered nothing. Not an error, not a crash — a correctly-sized, perfectly empty rectangle where the chart should be. The layout was right. The space was reserved. Nothing was painted.
The uncomfortable part wasn't the bug. It was that the chart had tests, the tests were green, and they had been green the whole time.
What the tests were actually asserting
The chart's test suite looked reasonable. It rendered the component, found it by its semantics, and checked the things you check:
composeTestRule.onNodeWithContentDescription("Bar chart with 4 categories…")
.assertExists()
.assertIsDisplayed()
.assertWidthIsAtLeast(200.dp)
Every one of those passed while the component drew nothing at all.
They passed because semantics and pixels are two different trees. assertExists asks whether a node is in the semantics tree. assertIsDisplayed asks whether that node's layout bounds intersect the visible window. assertWidthIsAtLeast asks about layout bounds again. Not one of them asks the only question that matters for a Canvas component: did anything get drawn?
For a Button, this distinction rarely bites, because a button that lays out correctly almost always draws correctly — its rendering is Material's problem, not yours. For anything you paint yourself, the gap is wide enough to drive a release through.
The actual bug
Here's the code that caused it, reduced:
Canvas(modifier = modifier.fillMaxWidth().height(180.dp)) {
drawBars(entries, size)
}
Looks fine. It isn't, in the general case — and the reason is worth internalising: Canvas is a Spacer under the hood, and its measure policy reports zero for any dimension that isn't fixed. Give it a modifier chain where height resolves to a constraint rather than a concrete value and DrawScope.size comes back with a zero dimension, so every draw call is a no-op against an empty area. Meanwhile the layout slot is exactly the size you asked for, because the parent's constraints filled it in.
That is the precise shape of a bug semantics assertions cannot see. Layout bounds: plausible. Draw size: zero. Test suite: green.
The fix is to separate the two concerns — let a Box own sizing and semantics, and let the Canvas fill it:
Box(modifier = modifier.fillMaxWidth().height(180.dp).semantics { … }) {
Canvas(Modifier.matchParentSize()) {
drawBars(entries, size)
}
}
But fixing the bug is the easy half. The question that actually mattered was: how do I write a test that would have caught this?
Why captureToImage() doesn't work under Robolectric
The obvious answer is pixel assertions. Compose ships captureToImage() for exactly this, and FormaUI's UI tests run on Robolectric — fast, on the JVM, no emulator. So:
composeTestRule.onNodeWithTag("chart").captureToImage()
ComposeTimeoutException: Condition still not satisfied after 2000 ms
Every time. Every component. Not just charts — a plain Text does the same thing. Changing GraphicsMode, setting robolectric.pixelCopyRenderMode, waiting for idle, none of it helps.
The reason is structural rather than a configuration mistake. captureToImage() captures the window, and before it does, it runs a forceRedraw step: it registers a frame-commit / onDraw callback and waits for a frame-driven draw pass to fire it. Under Robolectric there is no frame loop. Compose's own source says so, in RobolectricIdlingStrategy — "Draw passes don't happen." So the callback never fires, the wait always expires, and you get a timeout that looks like a flake and isn't one.
No amount of retrying fixes a callback that is never going to be called.
Calling View.draw directly
If the problem is that nothing will schedule a draw pass, the answer is to stop waiting for one and dispatch it yourself. View.draw(Canvas) renders synchronously, right now, on the calling thread — no frame loop involved:
internal fun SemanticsNodeInteraction.captureNodeToImage(): ImageBitmap {
val node = fetchSemanticsNode("Failed to capture the node to a bitmap.")
val view = (node.root as ViewRootForTest).view
check(view.width > 0 && view.height > 0) {
"Cannot capture: host view has degenerate size ${view.width}x${view.height}."
}
val fullBitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
view.draw(Canvas(fullBitmap))
val bounds = node.boundsInRoot
val left = bounds.left.roundToInt().coerceIn(0, view.width - 1)
val top = bounds.top.roundToInt().coerceIn(0, view.height - 1)
val width = bounds.width.roundToInt().coerceAtMost(view.width - left)
val height = bounds.height.roundToInt().coerceAtMost(view.height - top)
return Bitmap.createBitmap(fullBitmap, left, top, width, height).asImageBitmap()
}
Get the host View from the semantics node's root, draw it into a software bitmap, crop to the node's bounds. Thirty lines, no new dependency.
The trap underneath the trap
That still returned blank bitmaps.
Robolectric has two graphics backends. The default in many projects — including this one — is LEGACY, the shadow implementation, where drawPath, drawArc and drawLine are raster no-ops and rectangle fills rasterize as hairline outlines. Your capture is honest; there genuinely is nothing in the bitmap, because the drawing operations quietly did nothing.
This one is worth verifying yourself rather than believing me, because it invalidates a whole category of test. Take Compose out of the picture entirely, allocate an android.graphics.Canvas, draw a filled rect and a path onto it, and read the pixels back. Under LEGACY they aren't there.
The fix is one annotation:
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34])
class ChartPixelRenderTest { … }
NATIVE mode rasterizes every operation correctly using the org.robolectric:nativeruntime artifacts, which Robolectric has probably already cached. Tests run marginally slower. The bitmaps contain what was drawn.
What the test finally looks like
With capture working, the assertion is almost embarrassingly simple — and that's the point. It doesn't compare against a golden image, because golden images for charts are a maintenance tax that pays out mostly in false failures on font-rendering changes. It just asserts that the component painted something other than the background:
@Test
fun barChart_actuallyPaintsPixels() {
composeTestRule.setContent {
FormaTheme { FormaBarChart(entries = sampleEntries, animationSpec = null) }
}
val image = composeTestRule.onNodeWithTag("chart").captureNodeToImage()
assertTrue(
"Chart region contains only background pixels — nothing was drawn.",
image.hasNonBackgroundPixels(),
)
}
Note animationSpec = null. The charts animate on entry, so under test you want a deterministic final frame rather than a race against an 800ms tween. If your canvas component animates and doesn't offer a way to switch that off, that's a testability gap worth closing — you'll want it for screenshots too.
This assertion is deliberately weak. It cannot tell a correct chart from a wrong one. What it can tell you is the difference between a chart and an empty rectangle, which is the failure that actually shipped, survived a green test suite, and was found by a user.
The general rule
If a component paints its own pixels — charts, sparklines, progress rings, signature pads, custom dividers, anything holding a Canvas or a drawBehind — your semantics assertions are describing a component you have not verified renders. They're not worthless; they're just answering a different question than the one you think you asked.
The bar to hold for those components:
- Assert on pixels, not only on semantics.
- Don't reach for
captureToImage()on Robolectric — it cannot work there, and the timeout will read as flakiness for as long as you let it. - Draw the host
Viewinto a bitmap yourself and crop to the node. - Run those tests in
GraphicsMode.NATIVE, or your capture will be blank and you'll trust a blank capture. - Keep the assertion coarse. "Something was drawn" catches the bug that ships. Golden images mostly catch font updates.
FormaUI's charts now carry eight pixel-render tests built on that helper. They'd have caught the original bug in about a second — which is roughly how long it took a user to spot it, and considerably less time than it took me to work out why the obvious test infrastructure couldn't.
FormaUI is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done, including bar, line and donut charts with no third-party chart dependency. Try every component live in your browser.
Top comments (0)