個人開発で長く保守している壁紙アプリの設定画面に、AdMob バナーを一枚だけ置いています。ある晩、その画面のスクリーンショットテストを整えようとして、手が止まりました。同じコード、同じ端末設定、同じロケール。それなのに、実行するたびにテストが赤くなるのです。差分ビューアを開くと、変わっていたのは広告クリエイティブの中身だけでした。
最初は「たまたま」と思い、無視することにしました。ところが、そのうち本当に見るべきレイアウトの崩れが混ざり込んでも、私はもう差分ビューアを開かなくなっていました。狼が来たと叫び続けたテストは、狼が来た日に信じてもらえません。広告つきの画面を守るには、まず広告そのものを検知の外へ追い出す必要があります。
なぜ広告つきの画面は毎回赤くなるのか
スクリーンショットテストは、描画結果をピクセル単位で基準画像と比べます。ここに、開発者が制御できない要素が一つでも混ざると、テストは本質的に非決定的になります。AdMob バナーは、その最たるものです。
広告の中身はサーバーが毎回決めます。読み込みのタイミングによっては、キャプチャ時にまだ空だったり、途中まで描かれていたりもします。表示される高さも、クリエイティブによって数ピクセル揺れることがあります。次の表は、広告つき画面に潜む非決定性の要因を整理したものです。
| 要因 | 症状 | 封じ方の方針 |
| クリエイティブの内容 | 毎回まったく違う画像・文言が入る | テスト時は広告を描かず固定プレースホルダーへ差し替える |
| 読み込みタイミング | 空・半分・完了で結果が変わる | そもそも実広告を読み込ませない |
| 実測高さの揺れ | 数ピクセルのずれで周囲がずれる | スロットの高さを固定し、レイアウトを契約として検証 |
| ネットワーク依存 | CI で読み込み失敗し結果が不安定 | テストをネットワークから切り離す |
要点は一つです。広告を「うまく比較する」のではなく、「比較の対象から外す」。そこを起点に設計します。
広告の描画方針をコードから切り離す
いちばん効いたのは、広告を描くかどうかを画面のコードに埋め込まず、外から差し替えられるようにしたことです。Compose なら CompositionLocal がちょうど良い道具でした。画面は「ここに広告スロットがある」とだけ宣言し、実際に何を描くかは呼び出し側が決めます。
enum class AdRendering { LIVE, PLACEHOLDER }
// 既定は本番描画。テストだけがこれを差し替える
val LocalAdRendering = staticCompositionLocalOf { AdRendering.LIVE }
@Composable
fun AdBannerSlot(modifier: Modifier = Modifier) {
when (LocalAdRendering.current) {
AdRendering.PLACEHOLDER ->
// 実バナーと同じ実測高さの箱を置く。中身は決定的
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 公式のテスト用バナー ID。実 ID は本番設定から注入する
adUnitId = "ca-app-pub-3940256099942544/6300978111"
loadAd(AdRequest.Builder().build())
}
}
)
}
}
画面側は、広告があることを知りつつも、その中身には関与しません。
@Composable
fun SettingsScreen(state: SettingsState) {
Column {
SettingsHeader(title = "設定")
SettingsList(items = state.items)
AdBannerSlot() // 何を描くかは LocalAdRendering が決める
SettingsFooter(version = state.version)
}
}
この一手で、テストは「広告を待つ」も「広告を比べる」もしなくなります。描かれるのは常に同じ灰色の箱だけです。広告 SDK をテスト用に差し替えるライブラリを探すより、描画の分岐点を一つ設けるほうが、依存も設定も増えずに済みました。
スクリーンショットテストではプレースホルダーを流し込む
あとは、テストのときだけ LocalAdRendering をプレースホルダーに差し替えます。ここでは Robolectric 上で動く Roborazzi を使った例を挙げますが、考え方は Paparazzi でも同じです。
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SettingsScreenScreenshotTest {
@get:Rule val composeRule = createComposeRule()
@get:Rule val roborazziRule = RoborazziRule(composeRule = composeRule)
@Test
fun settings_screen_default() {
composeRule.setContent {
CompositionLocalProvider(LocalAdRendering provides AdRendering.PLACEHOLDER) {
AppTheme {
SettingsScreen(state = previewSettingsState)
}
}
}
composeRule.onRoot().captureRoboImage()
}
}
previewSettingsState は、テスト用に固定した状態です。日時やランダム要素を含むなら、ここも決定的な値へ固定しておきます。広告だけを封じても、他に非決定的な要素が残っていれば、同じ問題が別の場所で再発するからです。
ピクセル一致ではなくレイアウト契約を検証する
広告を箱に置き換えると、今度は「その箱が正しい寸法と位置に収まっているか」を確かめたくなります。ここはピクセル比較の役目ではありません。セマンティクス上のアサーションで、レイアウトの契約を明示的に検証します。
@Test
fun ad_slot_keeps_its_contract() {
composeRule.setContent {
CompositionLocalProvider(LocalAdRendering provides AdRendering.PLACEHOLDER) {
AppTheme { SettingsScreen(state = previewSettingsState) }
}
}
// スロットは想定どおりの高さを保つ
composeRule.onNodeWithTag("ad_slot").assertHeightIsEqualTo(50.dp)
// 広告の上下にある要素が、押し出されたり隠れたりしていない
composeRule.onNodeWithText("設定").assertIsDisplayed()
composeRule.onNodeWithTag("settings_footer").assertIsDisplayed()
}
こう分けると、二つの問いが別々に立ちます。「見た目のピクセルは基準どおりか」はスクリーンショットが答え、「広告スロットのぶんだけ他の要素が正しく詰まっているか」はレイアウトのアサーションが答えます。広告の中身が変わっても、どちらの問いも揺れません。次の表が、両者の使い分けです。
| 検証したいこと | 使う手段 | 広告差分に強いか |
| 画面全体の見た目 | プレースホルダーを流したスクリーンショット | 強い(広告は描かれない) |
| 広告スロットの寸法 | assertHeightIsEqualTo などの寸法検証 | 強い |
| 周囲要素の押し出し・クリップ | assertIsDisplayed と重なり検証 | 強い |
| 実広告の実配信確認 | 手動確認または実機の別テスト | スクリーンショットには載せない |
差し替えられない場面ではスロットをマスクする
実機のエミュレータ上で AdView をそのまま描かざるを得ないこともあります。統合テストで実際の読み込み挙動まで見たいときなどです。この場合は、比較時に広告領域だけを除外します。スロットには testTag("ad_slot") を付けてあるので、その位置と寸法を取り、基準画像との比較からその矩形を外します。
// 比較前に、ad_slot の矩形を取得してマスク領域として渡す
val bounds = composeRule.onNodeWithTag("ad_slot")
.fetchSemanticsNode()
.boundsInRoot
val maskRect = Rect(
left = bounds.left.toInt(),
top = bounds.top.toInt(),
right = bounds.right.toInt(),
bottom = bounds.bottom.toInt(),
)
// maskRect をお使いの比較ツールの除外領域として設定する
マスクは最後の手段です。除外した矩形の内側は一切検証されなくなるため、広告スロットの寸法だけは別途アサーションで押さえておきます。マスクとレイアウト契約はセットで初めて意味を持ちます。
Antigravity に差分トリアージを任せるときの指示
スクリーンショットの差分確認は、地味に時間を食う作業です。プレースホルダー化まで済ませたうえで、残った差分の一次仕分けを Antigravity のエージェントに委ねると、レビューの負荷がぐっと軽くなります。ここで大切なのは、エージェントに「広告領域は判断材料にしない」と明示的に伝えることです。曖昧なままだと、封じたはずの非決定性がトリアージ側から再び混入します。
エージェント向けの指示は、リポジトリの AGENTS.md に固定しておくと、実行ごとにぶれません。
## スクリーンショット差分のトリアージ方針
- 差分画像を読み、変化がどの領域で起きているかを最初に述べる。
- `ad_slot` の矩形内だけの変化は、常に「広告由来・要対応なし」として扱う。
この領域を根拠に不合格を出さない。
- ad_slot の外側に変化がある場合のみ、崩れの疑いとして要点を1〜2行で報告する。
- 寸法アサーション(高さ・表示・重なり)が失敗しているときは、
スクリーンショットの見た目より、そのアサーションの失敗を優先して報告する。
Antigravity 2.0 はアーティファクトを根拠に結論を出す作りなので、差分画像と寸法アサーションの結果という二つの証拠を渡しておくと、判断が安定します。人が最終確認するのは、エージェントが「スロットの外に変化がある」と言った差分だけで済むようになります。
実際に運用を切り替えて変わったこと
私の運用では、この設計へ移す前、設定画面まわりのスクリーンショット失敗のうち、体感で七割から八割が広告差分による誤検知でした。赤いテストが常態化し、レビューでは差分ビューアを開かずに再実行で流す癖がついていました。検知は形だけ残り、実質は機能していなかったということです。
広告の描画方針を切り離し、レイアウト契約のアサーションを足してからは、広告由来の誤検知はほぼ消えました。残る差分は、意図した UI 変更か、本当に見るべき崩れのどちらかです。差分ビューアをもう一度開く気になったのは、開けば必ず意味のある変化が待っているとわかったからでした。
導入の順番として私が勧めたいのは、まず広告スロットの CompositionLocal 切り離しだけを入れて、既存のスクリーンショットテストを一度全部撮り直すことです。ここまでで誤検知の大半は消えます。レイアウト契約のアサーションと Antigravity への委譲は、その手応えを確かめてから足しても遅くありません。
小さな一枚のバナーが、テスト全体の信頼を静かに削っていました。まず一画面だけでも、広告を検知の外へ追い出してみてください。実装の参考になれば幸いです。お読みいただき、ありがとうございました。