Protecting Screenshot Tests for AdMob Screens from Ad Nondeterminism
Screens with ads turn red on every screenshot run, and eventually nobody reviews the diffs. Here is a design that seals off AdMob banner nondeterminism and leaves only real layout breaks in your checks, with Compose code and Antigravity-driven diff triage.
I keep a single AdMob banner on the settings screen of a wallpaper app. One night, trying to tidy up the screenshot tests for that screen, my hand stopped. Same code, same device configuration, same locale — and yet the test went red on every run. When I opened the diff viewer, the only thing that had changed was the ad creative inside the banner.
At first I told myself it was a fluke and decided to ignore it. But before long, even when a genuine layout break slipped in, I had stopped opening the diff viewer at all. A test that cries wolf every day is not believed on the day the wolf arrives. To protect a screen with an ad on it, you first have to push the ad itself out of what you check.
Why an ad-bearing screen turns red every time
A screenshot test compares rendered output against a baseline pixel by pixel. Introduce even one element the developer cannot control, and the test becomes fundamentally nondeterministic. An AdMob banner is the prime example.
The server decides the ad's contents on every request. Depending on load timing, the slot might be empty at capture, or drawn halfway. Even the rendered height can drift by a few pixels between creatives. The table below organizes the sources of nondeterminism hiding in an ad-bearing screen.
Source
Symptom
How to seal it
Creative contents
A completely different image or copy each run
Swap the ad for a fixed placeholder during tests
Load timing
Empty, half, or complete changes the result
Do not load a real ad at all
Measured height drift
A few pixels shift everything around it
Fix the slot height and verify layout as a contract
Network dependence
Load fails in CI, making the result unstable
Detach the test from the network
The point is singular. Do not try to "compare the ad well" — take it out of the comparison. Design outward from there.
Decouple the ad rendering policy from the code
What helped most was refusing to bake the decision of whether to draw an ad into the screen's code, and instead making it swappable from outside. In Compose, CompositionLocal was exactly the right tool. The screen only declares "there is an ad slot here," and the caller decides what actually gets drawn.
enum class AdRendering { LIVE, PLACEHOLDER }// Default is live rendering. Only tests swap this out.val LocalAdRendering = staticCompositionLocalOf { AdRendering.LIVE }@Composablefun AdBannerSlot(modifier: Modifier = Modifier) { when (LocalAdRendering.current) { AdRendering.PLACEHOLDER -> // A box with the same measured height as the real banner. Deterministic contents. Box( modifier .fillMaxWidth() .height(50.dp) .background(Color(0xFFECECEC)) .testTag("ad_slot") ) AdRendering.LIVE -> AndroidView( modifier = modifier .fillMaxWidth() .height(50.dp) .testTag("ad_slot"), factory = { ctx -> AdView(ctx).apply { setAdSize(AdSize.BANNER) // Google's official test banner ID. Inject the real one from production config. adUnitId = "ca-app-pub-3940256099942544/6300978111" loadAd(AdRequest.Builder().build()) } } ) }}
The screen knows an ad exists, yet has no involvement in its contents.
With that one move, the test no longer waits for an ad, nor compares one. What gets drawn is always the same gray box.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A design that decouples the ad rendering policy via CompositionLocal to eliminate false positives from ad diffs, with a Compose implementation
✦When to drop pixel-exact matching in favor of layout-contract assertions, with concrete tests for height, clipping, and overlap
✦How to word the instruction that lets an Antigravity agent triage screenshot diffs while excluding the ad region
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
From there, you only swap LocalAdRendering for the placeholder during tests. Here is an example using Roborazzi running on Robolectric, but the idea is the same with Paparazzi.
previewSettingsState is a state fixed for testing. If it contains dates or random elements, pin those to deterministic values too. Sealing off the ad alone is not enough — if any other nondeterministic element remains, the same problem simply resurfaces elsewhere.
Verify the layout contract, not pixel identity
Once the ad becomes a box, you will want to confirm that the box sits at the right size and position. That is not the job of pixel comparison. Assert the layout contract explicitly with semantics-level assertions.
@Testfun ad_slot_keeps_its_contract() { composeRule.setContent { CompositionLocalProvider(LocalAdRendering provides AdRendering.PLACEHOLDER) { AppTheme { SettingsScreen(state = previewSettingsState) } } } // The slot keeps its expected height composeRule.onNodeWithTag("ad_slot").assertHeightIsEqualTo(50.dp) // Elements above and below the ad are neither pushed out nor hidden composeRule.onNodeWithText("Settings").assertIsDisplayed() composeRule.onNodeWithTag("settings_footer").assertIsDisplayed()}
Split this way, two questions stand separately. "Do the visible pixels match the baseline?" is answered by the screenshot; "Are the other elements packed correctly around the ad slot?" is answered by the layout assertions. Even when the ad's contents change, neither question wavers. The table below shows how to divide the two.
What you want to verify
Means
Robust to ad diffs?
The whole screen's appearance
Screenshot with the placeholder fed in
Yes (no ad is drawn)
The ad slot's dimensions
Height checks like assertHeightIsEqualTo
Yes
Push-out or clipping of neighbors
assertIsDisplayed and overlap checks
Yes
Confirming real ad delivery
Manual check or a separate on-device test
Keep it out of the screenshot
Where you cannot swap, mask the slot
Sometimes you have to draw the AdView as-is on an emulator — say, an integration test where you want to observe the real load behavior. In that case, exclude only the ad region at comparison time. The slot carries testTag("ad_slot"), so take its position and size and drop that rectangle from the baseline comparison.
// Before comparing, fetch the ad_slot rectangle and pass it as the mask regionval bounds = composeRule.onNodeWithTag("ad_slot") .fetchSemanticsNode() .boundsInRootval maskRect = Rect( left = bounds.left.toInt(), top = bounds.top.toInt(), right = bounds.right.toInt(), bottom = bounds.bottom.toInt(),)// Set maskRect as the exclusion region for your comparison tool
Masking is a last resort. Since nothing inside the excluded rectangle is checked, hold down at least the ad slot's dimensions with a separate assertion. A mask and a layout contract only mean something as a pair.
The instruction for handing diff triage to Antigravity
Reviewing screenshot diffs is quietly time-consuming work. Once the placeholder swap is in place, delegating the first-pass sorting of the remaining diffs to an Antigravity agent lightens the review load considerably. What matters here is telling the agent explicitly to "treat the ad region as not evidence." Left vague, the nondeterminism you sealed off creeps back in from the triage side.
Pin the agent-facing instruction in the repository's AGENTS.md so it does not drift from run to run.
## Screenshot diff triage policy- Read the diff image and first state which region the change occurs in.- Changes only inside the `ad_slot` rectangle are always treated as "ad-origin, no action needed." Never fail on the basis of this region.- Only when there is a change outside ad_slot, report the point in one or two lines as a suspected break.- When a dimension assertion (height, display, overlap) fails, prioritize reporting that failure over the screenshot's appearance.
Antigravity 2.0 is built to draw conclusions from artifacts, so handing it two pieces of evidence — the diff image and the dimension-assertion results — keeps its judgment stable. What a human finally confirms narrows to just the diffs where the agent said "there is a change outside the slot."
What changed after switching the workflow over
In my own operation, before moving to this design, roughly seventy to eighty percent of the screenshot failures around the settings screen were false positives from ad diffs. A red test became the norm, and in review I fell into the habit of re-running rather than opening the diff viewer. The check remained in form only; in substance it had stopped working.
After decoupling the ad rendering policy and adding the layout-contract assertions, ad-origin false positives all but vanished. The diffs that remain are either intended UI changes or breaks I genuinely need to see. What made me willing to open the diff viewer again was knowing that opening it would always mean a meaningful change was waiting.
For the order of adoption, I would suggest putting in only the CompositionLocal decoupling of the ad slot first, then re-recording all your existing screenshot tests once. That alone clears most of the false positives. The layout-contract assertions and the handoff to Antigravity can wait until you have felt that first result.
A single small banner was quietly eroding trust in the whole test suite. Even for just one screen, try pushing the ad out of what you check. I hope this helps with your own implementation, and thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.