devsnack

AdMob Test Ads: A Demo Unit Id Is Not Test Mode

Demo units cover the debug build and nothing else. Registering a test device, the consent call that must land first, and the permission the SDK declares for you.

DevSnack28 Aug 2026 · 11 min

You paste Google's demo banner id into the template, ads fill immediately, and the layout looks right. Before release you swap in the unit id from your own AdMob account, build in release mode, and open the app on your phone to check it still looks right. That last sentence is the one that costs people their account.

A demo unit id makes the ad fake. It does not make your device a test device. Those are two separate switches with two separate APIs, and the demo ids only cover the half of your work that happens before you have real units to check. The moment you run your own ids on an unregistered handset, you are looking at live inventory that a real advertiser is paying for.

The crash-handling side of AdMob — listeners firing into disposed widgets, preloading interstitials, where not to show one — is its own problem and has its own post. This is the plumbing either side of it: the ids, the consent call, the permission you did not add, and the file Google looks for on a website you may not have.

Two switches, not one

Google's demo units serve house inventory that belongs to nobody. Requests, impressions and clicks against them never reach your reports, which is exactly why they are safe and exactly why they tell you nothing about whether your own units are configured correctly.

Registering a test device is the other switch. It serves test fills through your real ad unit ids, so you can confirm that the unit exists, that the format matches, and that the mediation you set up returns something — without a single billable impression. The SDK prints the id you need on the first request, in logcat on Android and the Xcode console on iOS.

lib/main.dart
await MobileAds.instance.initialize();

// The id is per device and per app reinstall. Read it from
// the log line the SDK prints on the first ad request:
//   "Use RequestConfiguration.Builder.setTestDeviceIds(
//    Arrays.asList("33BE2250B43518CCDA7DE426D04EE231"))
//    to get test ads on this device."
if (kDebugMode || kProfileMode) {
  await MobileAds.instance.updateRequestConfiguration(
    RequestConfiguration(testDeviceIds: ['33BE2250B43518CCDA7DE426D04EE231']),
  );
}

Wrap it in a build-mode check rather than shipping the list. A test device id in a production binary is harmless for the one handset it names and pointless for everyone else, but it is also a permanent invitation for somebody to keep testing against production. Emulators and the iOS simulator are already test devices and need no registration.

There is a third state that looks like a bug and is not. A newly added app sits at Getting ready in AdMob until it has been linked to a store listing and reviewed, and it serves either limited ads or none while that runs — typically a couple of days, sometimes longer. Real units returning nothing on launch week is usually this, not your code, and no amount of re-reading the load callbacks will find it. The app list in AdMob is the only place the state is visible.

The id that identifies the build, not the ad

An app id and an ad unit id look almost identical and are not interchangeable. The separator is the tell: an app id uses ~, an ad unit id uses /. The app id belongs in the platform manifest and is read once at initialisation. It never goes in an ad request.

android/app/src/main/AndroidManifest.xml
<meta-data
    android:name="com.google.android.gms.ads.APPLICATION_ID"
    android:value="ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" />
ios/Runner/Info.plist
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY</string>

Get this wrong and the SDK does not fail politely at request time. On Android a missing or malformed APPLICATION_ID throws during initialisation and takes the app down at launch, which on a bought template reads like the template is broken. It is worth checking before anything else when a rebranded build crashes on first run: the id in the manifest is still the template author's, or was replaced with a unit id by someone who could not see the difference.

Consent has to land before the first request

For traffic from the EEA and the UK, an ad request without a consent decision is not a request that earns less — it is a policy problem attached to your account. Google ships the User Messaging Platform inside google_mobile_ads, so there is no extra dependency to add on Flutter, only a sequence to get right.

lib/ads/consent.dart
Future<bool> gatherConsent() async {
  final completer = Completer<void>();

  ConsentInformation.instance.requestConsentInfoUpdate(
    ConsentRequestParameters(),
    () async {
      // Shows a form only where one is required. It is a no-op
      // outside the regions that need it, so it is safe to call
      // on every launch — which is what Google asks for.
      await ConsentForm.loadAndShowConsentFormIfRequired((error) {
        completer.complete();
      });
    },
    (error) => completer.complete(),
  );

  await completer.future;
  // The gate. Initialise the ads SDK only once this is true.
  return ConsentInformation.instance.canRequestAds();
}

Two details decide whether this works in practice. Call it on every launch, not once at first run — consent expires and regions change. And keep a route into ConsentForm.showPrivacyOptionsForm from your settings screen whenever getPrivacyOptionsRequirementStatus asks for one, because a missing privacy-options entry point is a review finding rather than a revenue one.

You can test the European path without leaving your desk. ConsentDebugSettings takes the same hashed device identifier as the ad test-device list, alongside DebugGeography.debugGeographyEea, and ConsentInformation.instance.reset() puts the device back to a first-run state so you can watch the form appear more than once.

The permission you never declared

Sooner or later the Play Console tells you your manifest includes com.google.android.gms.permission.AD_ID, and you search the project and cannot find it. It is not in your manifest. The Mobile Ads SDK declares it in its own, and manifest merging folds it into your build.

This is correct and you should keep it. Apps targeting Android 13 (API 33) and above that use the advertising ID have to declare it; without the declaration the ID is replaced with a string of zeroes. Nothing errors. Personalisation simply stops, the ads keep rendering, and the earnings difference shows up nowhere in the SDK logs.

android/app/src/main/AndroidManifest.xml
<uses-permission
    android:name="com.google.android.gms.permission.AD_ID" />

<!-- Only if you genuinely do not use the advertising ID —
     for a kids-category app, for instance. tools:node="remove"
     strips what the SDK merged in. -->
<uses-permission
    android:name="com.google.android.gms.permission.AD_ID"
    tools:node="remove" />

Whichever you choose, the Data safety declaration on the listing has to agree with it. A form that says you do not collect device identifiers, sitting on a build whose merged manifest asks for the advertising ID, is a mismatch reviewers do catch.

app-ads.txt is crawled from your store listing

An app-ads.txt file declares which ad networks are allowed to sell your inventory. Buyers that have adopted the standard will not bid on apps without a verified file, so the practical effect of not having one is fewer bidders and a lower price on every impression.

The part nobody mentions: Google does not ask you where the file is. The crawler takes the developer website URL from your store listing, reduces it to a hostname, and looks for /app-ads.txt there. Change the listing and it can take a day for the crawl to catch up.

https://yourdomain.com/app-ads.txt
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0

Which means the prerequisite is a website. If you shipped a bought template with the developer website field empty — and the field is optional, so plenty of people do — there is no hostname to crawl and no route to a verified file at all. A single static page on a domain you own is enough.

What none of this earns you

The mirror-image mistake is worth naming. A demo unit id left in a production build serves perfectly: users see ads, the layout is right, nothing crashes, and you earn nothing at all. Because it looks exactly like a working integration, it survives testing and is usually found by wondering why the earnings page is still zero a fortnight after launch. Pick the id by build mode, in one file, rather than by editing a constant before each release.

And none of this is about revenue. Fill rate, eCPM, mediation waterfalls and where to place a banner are optimisation questions with real answers, and none of them are policy questions. Everything above is the floor — the set of things that decide whether the account stays open, not how much it makes.

Register the device, then stop thinking about it

The order that works: demo units while you are building the screen, your real units plus a registered test device once the screen is done, and the id chosen by build mode so no human has to remember the swap. Add the consent gate before the first request rather than after the first policy email, and put a static app-ads.txt on the domain in your listing the same week you publish. Add the app in AdMob early enough that its review has finished before launch day rather than during it.

Everything on that list takes an afternoon, once, and every item on it is something that only announces itself after it has already cost you something.

AdMob Test ID Cheatsheet

Every Google demo unit id — banner, interstitial, rewarded, native, app open — for Android and iOS, next to the sample app ids. Copy-paste, no account.

Open the cheatsheet