mobile-qaiap

Testing In-App Purchases in a mobile game: 9 sections and a checklist

IAP is the most expensive code segment for a bug. One missed case of “money charged, item not granted” means support tickets, refunds, bad reviews, and retention at risk. And at the same time, IAP coverage in mobile games is usually the weakest part of QA: “works in sandbox on my device” is considered the norm. Here’s a full guide on how to actually test IAP.

Why IAP is harder than a regular API

  • Transactions go through an intermediary (App Store / Play Store), not directly into your backend. Request → platform → your client code → your server → confirmation → grant. Any link can break independently.
  • Server-side receipt validation — critical, but often skipped. Without it, a client can “forge” a successful purchase.
  • Restore Purchases — a separate, often-forgotten code path. The user switched devices, reset progress, reinstalled the app.
  • Subscriptions have their own lifecycle: grace period, billing retry, upgrade/downgrade with proration, family sharing, paused subscriptions.
  • Region and currency affect price, taxes, availability. What works in the US store may not work in Turkey or India.

1. Setup: sandbox and test accounts

iOS

  • App Store Connect → Users and Access → Sandbox Testers — create separate test Apple IDs. Never use your real one.
  • On the device: Settings → App Store → Sandbox Account → log in with the test ID. Don’t sign out of your main account — the sandbox login is separate from the device’s Apple ID.
  • If the app uses StoreKit 2 — sandbox mode works without a separate account login when launched from Xcode. Convenient for dev, but QA flows from TestFlight still go through Sandbox Tester.

Android

  • Google Play Console → Setup → License testing — add the testers’ Google accounts. These accounts see test prices (₽0) and can complete a full flow.
  • For testing you need: a closed/internal/open testing track in Play Console, the test account added to the track, the app downloaded from Play Store via the track’s link.
  • Important: IAP doesn’t work over adb install — only via Play install. Otherwise Billing returns BILLING_UNAVAILABLE.

2. Functional: core scenarios

Minimum set of cases that every IAP in your product must pass:

  • Successful purchase: tap → confirmation → item added to balance/inventory → analytics event sent → purchase_token / transaction_id logged.
  • User cancelled the payment dialog: returns to normal state, no partial effects, no charge of points or money.
  • Double-tap on Buy: request sent only once. Multiple item grants is a classic bug.
  • App killed mid-transaction: after relaunch the app sees a pending transaction, finishes it, grants the item.
  • Purchase started offline: no network → button disabled or shows a clear error. Not a blank StoreKit dialog with no explanation.

3. Edge cases: network failures

The dirtiest class of bugs — when the payment went through on the platform side but the client didn’t find out. Simulate them:

  • Network drop after payment: purchase made it to App Store / Play, but your server doesn’t know. The platform holds the receipt — the app should pick it up on the next launch and grant the item.
  • Server 500 during receipt validation: client got the receipt, sent it to your backend, backend errored. Retry logic? How many times? At what intervals? Does the receipt get persisted locally for retry?
  • Slow network (10 sec timeout): platform returns the receipt after 8 seconds of the tap. Did the loading indicator hang? Did a “timeout” error appear too early? Use Network Conditioner — 3G / packet loss / high latency.
  • App backgrounded during payment: payment dialog popped up — user backgrounded the app. Returning a minute later — state is correct.

4. Restore Purchases

This button is mandatory for App Store review. Rarely tested thoroughly. Most common bugs live here.

  • Restore after device reset: bought No-Ads → reset device → reinstalled the game → signed in with same Apple ID → tap Restore → No-Ads should enable. No re-purchase needed.
  • Restore with a different Apple ID: bought on ID-A → signed in as ID-B → Restore. Nothing should restore, with a clear error message.
  • Restore with no purchases: never bought anything → tap Restore → doesn’t crash, shows “No purchases to restore”.
  • Double Restore: two taps in a row → one request to the store, no double grant.
  • Restore for consumables: consumable purchases (coins, gems) must not restore via Restore. Only non-consumable and subscriptions do. Logic: bought 100 coins → spent them → Restore must not return 100 coins.

5. Subscriptions: their own zoo of scenarios

If the game sells a subscription (Battle Pass, VIP, Monthly No-Ads) — there’s much more to test:

  • Auto-renew: an active subscription renewed automatically — did the client learn about it? Does the UI show the correct status? In iOS sandbox, subscriptions renew on an accelerated timeline (5 minutes instead of a month).
  • Upgrade / Downgrade: switching from Monthly to Yearly or back. In Play Console there are four proration modes: ImmediateWithTimeProration, Charge prorated, Without proration, Deferred. Verify which one you set, and that the user gets exactly what’s documented.
  • Grace period: subscription expired, payment failed → platform gives a 16-day grace period for retry. The user should keep premium access during this time.
  • Billing retry: iOS / Android retry the charge on their own. The UI should show a “Payment issue” with a deep link to Subscriptions settings.
  • Cancel mid-period: user cancelled via App Store Settings → access stays until the end of the paid period, doesn’t disappear immediately.
  • Refund: the user got a refund via support → your server receives a REFUND server notification → must revoke the grant. Often forgotten.
  • Family Sharing: if enabled — one family member’s subscription is available to all. The receipt arrives on each device with the same original_transaction_id.

6. Receipt validation: security

If your server doesn’t validate receipts with Apple / Google — you don’t have IAP. Anyone can ask the local code “give me the item” and it’ll work. What the server must do:

  • Receive receipt (iOS) or purchaseToken (Android) from the client.
  • Send it to the verifyReceipt endpoint on Apple or purchases.products.get API on Google. Apple guide, Google guide.
  • Verify: status (is it valid), bundle_id (is it our app), product_id (the right item), transaction_id (is this a duplicate — critical for consumables).
  • Only after successful validation — grant the item in the user’s DB. Only then return OK to the client.

QA check: ask a developer to substitute the platform response (via Proxyman Map Local) with a “success” with a forged receipt → your backend must refuse to grant. If it grants — you have a security bomb.

7. Price localization and regions

  • Price with correct currency symbol and format: ₽299 (RU), 2,99 € (DE), $2.99 (US), R$ 14,90 (BR). Don’t hardcode ”$” — use localizedPriceString (iOS) / formattedPrice (Android Billing 5).
  • VPN region: if the user is logged into the US App Store but physically in RU — the price must show by the US store, not by geo. Otherwise it’s a UX bug.
  • Availability: verify the product is active in all target countries. In App Store Connect — Pricing and Availability → list of countries. In Play Console — Pricing → Countries / regions.
  • Tax behaviour: in some regions tax is included, in others added on top. price in the API may be pre-tax or post-tax. Trust the store, don’t compute yourself.

8. Analytics and server reconciliation

A purchase is four events that must reconcile:

  • Client sent purchase_attempted.
  • Platform confirmed → client sends purchase_completed with amount, currency, product_id.
  • Server validated → sends its own purchase_validated (to BI).
  • Apple/Google send an S2S notification → server reconciles with what the client sent.

If any of these is missed — you have a hole in the funnel. Use Proxyman + Amplitude / Firebase real-time view simultaneously to verify.

9. Common bugs from production

  • Double-grant — the item was granted twice (e.g. after restoring a pending transaction). Root cause: no idempotency by transaction_id on the server.
  • Lost purchase — user paid, item didn’t arrive, support drags it out by hand. Root cause: receipt didn’t reach the server (network drop), pending isn’t reprocessed on next launch.
  • Stuck loading spinner — after tapping Buy a loading spinner appears and never goes away. Root cause: no timeout on the StoreKit promise, no error handling.
  • Wrong product price — UI shows $4.99 but $9.99 is charged. Root cause: price hardcoded instead of read from StoreKit SKProduct.price.
  • Grant without payment — a cracked client sends “purchased” to the backend without a receipt → item is granted. Root cause: no server-side validation.
  • Restore doesn’t work on a new device — moved to a new iPhone, Restore doesn’t bring back No-Ads. Root cause: backend stores entitlement by device-id, not by Apple ID.
  • Subscription “flicker” — UI shows Free for 1 second, then Premium, then Free again. Root cause: race between local receipt and server-validated state.

Checklist for every IAP

  • Sandbox account works, purchase succeeds, analytics event fired
  • Cancelling the payment dialog has no side effects
  • Double-tap doesn’t double-grant
  • Network drop mid-transaction — pending handled on next launch
  • Restore works: returns non-consumables and subscriptions, doesn’t return consumables
  • Receipt validated on the server, not on the client
  • Price shows localized (currency + format)
  • For subscriptions: auto-renew, cancel, grace period, refund — all checked
  • Server S2S notifications are handled (REFUND, CANCEL, RENEW)
  • Analytics fires all funnel events
  • Idempotency by transaction_id on the server — a repeated receipt doesn’t double-grant