Real AdMob ads not showing after publishing?
I flagged initializeForTesting as a silent zero-revenue bug in my Capacitor game, then unzipped the release AAB to see what it really ships. The plugin source says the flag was probably harmless.
Contents
In August I was wiring the real AdMob ad units into Word Slot, a Capacitor word game, ahead of its first release. Claude Code found a line in the ads hook while it worked, and we both read it the same way at the time: a release build that would serve test ads with production IDs configured, earn nothing, and show no symptom at all.
This post is what I did about it — mostly, how I checked what the release .aab actually
contains — and what I found when I went back to the plugin's source to write it up. That part
does not agree with the diagnosis. I never confirmed either version on a phone, and I say so at
the end.
The setup: Capacitor 8, @capacitor-community/admob 8.0.0 (per the lockfile), Vite 6, React.
The ad unit IDs live in a shared config package, and a small resolver returns Google's sample
units whenever the app's testMode is on.
The line
This is how the hook started the SDK on Android:
const isAndroid = Capacitor.getPlatform() === 'android';
await AdMob.initialize(isAndroid ? {
testingDevices: ['EMULATOR'],
initializeForTesting: true,
} : {});
Unconditional. Every Android build, debug or release, initialised "for testing". The note at the time said it would keep serving test ads whatever the config said, and the release would earn €0. The fix tied it to the same switch as the unit IDs:
const androidTesting = USE_TEST_ADS === true || USE_TEST_ADS === 'android';
await AdMob.initialize(isAndroid && androidTesting ? {
testingDevices: ['EMULATOR'],
initializeForTesting: true,
} : {});
Two switches flipped by hand
Fixing that line exposed the real weakness. Whether a build was "release" came down to two
constants in the source: testMode in the ad config and UNLOCK_ALL_STAGES in the game store.
Both had to be flipped by hand before a release and flipped back after. A constant like that is
wrong by default in one direction or the other: leave it on and the store gets an unlocked
campaign on test ads; leave it off and every dev session requests live ads, and clicking your
own live ads is the one thing AdMob tells you never to do.
So both became derived. One environment variable, VITE_RELEASE, set only by the release
script:
const isRelease = import.meta.env.VITE_RELEASE === '1';
const iosProvisioned = Boolean(admobConfig.iosAdUnits?.banner);
const testMode = !isRelease ? true : iosProvisioned ? false : 'ios';
That traded one risk for another. With no constant left in the source, a bundle built without
the variable looks exactly like one built with it, so a hand-run gradlew bundleRelease over
stale dev assets would ship test ads and unlocked stages without a sound. A Vite plugin now
stamps <meta name="build-mode"> into index.html, and the release script reads it back out of
the copy Capacitor synced into the Android project before Gradle runs. I wrote that guard up in
A release pipeline you cannot forget; here it
matters as one of the two things I checked in the artifact.
Checking the AdMob ads before publishing the app on Google Play
The release script printed a green line, a 17.0 MB .aab and the signer. None of that says
which ad units are inside. So I unzipped the bundle and asked it directly. The stamp first:
$ unzip -p app/build/outputs/bundle/release/app-release.aab \
base/assets/public/index.html | grep -o 'name="build-mode" content="[a-z]*"'
name="build-mode" content="release"
Then the production banner unit, which should be somewhere in the JavaScript. It took four attempts, and the first three all said it was not there.
Attempt one. I passed index-*.js to unzip as a path inside the archive. zsh tried to
expand the glob against my local disk first, found nothing, and the pipeline still printed a
count:
(eval):1: no matches found: base/assets/public/assets/index-*.js
0
Attempt two ran from the wrong directory. unzip: cannot find or open app/build/outputs/bundle/release/app-release.aab, and again a 0.
Attempt three listed the archive, took the first index-*.js with head -1, and grepped
that:
entry: base/assets/public/assets/index-AAAAAAAA.js
0
That one looked real, and for a minute it read as "the release ships without the production unit". Then I listed the files:
351 dist/assets/index-AAAAAAAA.js
931208 dist/assets/index-BBBBBBBB.js
Two files match index-*.js, and head -1 had picked the 351-byte one. Attempt four
grepped every match instead of the first:
== base/assets/public/assets/index-AAAAAAAA.js
0
== base/assets/public/assets/index-BBBBBBBB.js
1
The unit was in the 931 KB chunk all along. Three zeros, three different causes, none of them
about ads. A grep -c that prints 0 tells you the pipeline ran, not that it looked in the
right place.
What the grep does not prove
Finding the production ID in the bundle is weaker evidence than it feels. The same chunk also
carries Google's sample unit IDs, because the resolver's test table is compiled into every
build, release or not. And it carries the shared config object with testMode:!0 in it — the
old hand-flipped constant, which Word Slot no longer reads but which still ships. Grep the
bundle for testMode and you would conclude the release is in test mode.
So the ID being present says only that it can be used. Which branch the app takes is decided
at runtime by VITE_RELEASE, and the stamp in the HTML is my evidence that the variable was set
for this build. Together they make a decent case. Neither is an ad on a screen.
AdMob just showing test ads, not real ads: which switch decides
Writing this post, I went to the plugin's Android source to quote what initializeForTesting
does. Here is the part that reads it, from AdMob.java in 8.0.0:
final boolean initializeForTesting = call.getBoolean("initializeForTesting", false);
final JSArray testingDevices = initializeForTesting
? call.getArray("testingDevices", AdMob.EMPTY_TESTING_DEVICES)
: EMPTY_TESTING_DEVICES;
// …
RequestConfiguration requestConfiguration = new RequestConfiguration.Builder()
.setTestDeviceIds(testingDevices.<String>toList())
That is all it does. The flag decides whether the testingDevices list is passed to the SDK as
test device IDs. The plugin's type docs for 8.0.0 say the same thing in their own words: "If set
to true, the devices on testingDevices will be registered to receive test production ads." It
does not switch the whole app to test ads. Word Slot's list held one entry,
'EMULATOR', and a player's phone is not on it.
On that reading, the line I called a €0 bug would most likely not have changed what a player
saw. In this setup the switch that decides test ads against real ones is the unit ID handed to
showBanner and the interstitial and rewarded calls — Google's sample units when testMode is
on, production units when it is off. That is the switch the derived flag controls, and the one
the AAB check above looked at.
I am keeping the fix. It costs nothing, it says what was meant, and a test-device list in a release build is not something I want to reason about again. But I have not built a release with the old line restored and watched it serve live ads on a real phone, so "probably harmless" is a reading of the source, not a result.
If real AdMob ads are not showing after publishing
This is not an AdMob ads not showing troubleshooter; it covers one cause, the build shipping
the wrong switch. For that cause, the check takes two minutes and needs no AdMob dashboard:
unzip the .aab you uploaded, find the chunk that actually holds your config — every chunk,
not the first match — and see which unit IDs are there and what decides between them. If your
plugin has a "testing" option, read what it does in the plugin source before you trust a
comment, including your own.
The iOS side gets the same treatment. Word Slot has no iOS ad units yet, so its release build deliberately stays on Google's test units there. Without that rule, the resolver would hand the SDK an empty string as the unit ID. For "AdMob not showing ads, iOS" after a release, an empty unit ID is the first thing I would rule out. I have not built the iOS app, so I cannot tell you what the SDK does with one.
What I did not confirm
- No release build has been watched serving a live ad on a physical phone, before or after the change.
- No impressions or revenue from that build were checked in AdMob.
- The
initializeForTestingconclusion comes from reading the plugin's source and type docs, not from a test.
What is confirmed: the release .aab built on 24 August carried content="release" in its
synced HTML and the production banner unit in its main JavaScript chunk, found on the fourth
grep.