How to Test Subscription Purchase on Android (Complete Guide)

Testing subscription purchases on Android is a critical quality gate because revenue flows directly through this code path. A broken purchase flow can lead to lost revenue, charge‑backs, negative revi

June 15, 2026 · 18 min read · How-To Guides

Introduction

Testing subscription purchases on Android is a critical quality gate because revenue flows directly through this code path. A broken purchase flow can lead to lost revenue, charge‑backs, negative reviews, and even policy violations from Google Play. Unlike a UI button that merely navigates screens, a subscription purchase touches networking, cryptographic verification, server‑side state, and the Google Play billing infrastructure. When any of those pieces misbehave, the failure may only appear under specific conditions—network latency, a rooted device, a concurrent upgrade, or a particular user persona.

This guide walks you through a complete testing strategy: why subscriptions fail in production, a detailed test matrix, manual and automated techniques, concrete code and adb examples, edge‑case scenarios that surface only after release, accessibility and security considerations, and how persona‑driven autonomous exploration (e.g., SUSA) can surface bugs that scripted tests never think to try. By the end you will have a checklist you can run before each release and a set of patterns you can embed in your CI pipeline.

---

Understanding Android Billing Library

Before writing tests you must know what the library does and where the seams are for injection or observation.

Core concepts

Typical integration snippet (Kotlin)


class BillingRepository private constructor(
    private val context: Context,
    private val billingClient: BillingClient = BillingClient.newBuilder(context)
        .setListener { billingResult, purchases ->
            // handle updated purchases
        }
        .enablePendingPurchases()
        .build()
) {
    fun start() {
        billingClient.startConnection { result ->
            if (result.responseCode == BillingResponseCode.OK) {
                // ready to query
            }
        }
    }

    fun launchPurchaseFlow(activity: Activity, skuDetails: SkuDetails) {
        val flowParams = BillingFlowParams.newBuilder()
            .setSkuDetails(skuDetails)
            .setOldPurchaseToken("") // for upgrades/downgrades fill with current token
            .build()
        billingClient.launchBillingFlow(activity, flowParams)
    }

    // …acknowledge, consume, queryPurchases, etc.
}

*The repository isolates the billing client, making it easy to swap a test double or a mock.*

---

Test Matrix

The table below organizes scenarios by category, sub‑scenario, expected outcome, and suggested verification method. Use it as a checklist when you write manual test cases or automate them.

CategorySub‑scenarioExpected outcomeVerification method
Happy PathUser launches purchase flow with a valid test card, completes purchase, sees confirmation screenPurchase token generated, UI shows “Thank you”, backend receives valid purchase, subscription state ACTIVECheck logs for PurchaseUpdatedListener, call backend verification API, assert UI text
User restores purchases after clearing app dataPreviously active subscription is restored, UI shows premium featuresQuery BillingClient.queryPurchasesAsync after data clear, verify restored purchase
Error PathsNetwork loss during purchase flowFlow cancels, user sees error dialog, no purchase token createdDisable Wi‑Fi/mid‑flow via adb shell svc wifi disable, assert error message shown
User cancels at the Play Store confirmation screenFlow returns BillingResponseCode.USER_CANCELED, no tokenObserve onBillingResult callback, assert USER_CANCELED
Invalid product ID (typo)BillingResponseCode.ITEM_UNAVAILABLEQuery non‑existent SKU, verify error code
Declined test card (use Play Store’s “Always fail” test card)Purchase fails, user sees payment errorUse test card 4000 0000 0000 0002 (always decline)
Edge Cases – Purchase FlowRapid double‑tap on buy buttonOnly one purchase flow initiated; second tap ignored or shows “already in progress”Instrumentation test with performClick() twice, assert single launch
Orientation change while Play Store dialog is openDialog survives, purchase completes successfullyRotate device via adb shell settings put user_rotation 1, then complete flow
App goes to background (home key) during flowPurchase continues in Play Store; returning to app shows resultPress Home, wait, reopen app, verify purchase result
User has multiple active subscriptions (different tiers)Each subscription maintains independent token and statePurchase two different plans, verify both tokens present
Edge Cases – Subscription LifecycleUpgrade/downgrade with proration mode IMMEDIATE_AND_CHARGE_PRORATED_PRICEUser gains higher tier immediately, charged prorated amount on next billing dateSimulate upgrade via Play Console test offerings, check backend proration event
Free trial conversion to paidAfter trial period, first paid charge occurs, subscription stays ACTIVEFast‑forward time with adb shell am send-io or use Play Console license test to skip trial
Grace period after payment failureUser retains access for X days, subscription state IN_GRACE_PERIODSimulate card decline, wait for grace period, verify access and state
Account hold after grace period expiryAccess blocked, state ON_HOLDContinue failure, check state transition
Pause subscription (if enabled)User can pause for 1 week–3 months, access retained during pauseInitiate pause via Play Store, verify SUBSCRIPTION_STATE_PAUSED
Price change notificationUser sees dialog about upcoming price change, can accept or cancelTrigger price change in Play Console, verify dialog appears
AccessibilityTalkBack navigation through purchase flowAll controls have spoken labels, focus order logicalEnable TalkBack, swipe through flow, listen for missing labels
Font scaling up to 200%UI elements not clipped, buttons remain tappableSet Settings > Accessibility > Font size to largest, test flow
High contrast / dark themeText legible, contrast ratios ≥ 4.5:1Use developer options to force dark theme, verify with accessibility scanner
Security & PrivacyPurchase token not leaked in logcatNo token appears in adb logcat when flow completesRun flow, filter logcat for token substring, assert absence
Server verification uses HTTPS with certificate pinning (if implemented)Network calls to backend use TLS, reject self‑signed certsUse adb shell cmd netlog or Charles proxy to inspect
Receipt validation rejects replayed tokensRe‑submitting same token returns errorSend already‑acknowledged token again to backend, expect failure
App does not store raw purchase data in SharedPreferences unencryptedNo plain‑token persisted locallyInspect app’s data directory after purchase, verify token absent or encrypted
PerformancePurchase flow latency < 2 s on median device (5 yr old)Time from button click to Play Store confirmation < 2 sUse adb shell am start-activity -W to measure launch time, or Android Studio Profiler
Memory leak absent after repeated purchase attemptsHeap does not grow unbounded after 20 cyclesLoop purchase/cancel, monitor with adb shell dumpsys meminfo
RegressionAfter library upgrade (e.g., 4 → 5) all existing flows still workNo new failures introducedRun full matrix against both versions in a staging environment
Persona‑Driven (exploratory)Curious user taps every visible element before buyingNo stray clicks trigger unintended purchases or crashesAutonomous agent explores UI, logs any unexpected state changes
Impatient user spams back button during Play Store dialogDialog dismisses cleanly, app returns to prior screen without leaking tokenSimulate rapid back presses, verify no purchase created
Elderly user with increased touch toleranceLong presses are interpreted correctly, no false positivesAdjust touch size in accessibility settings, test flow
Adversarial user attempts to bypass purchase via UI automation (e.g., overlay)Overlay cannot interfere with Google Play purchase UI; purchase still requires legit flowAttempt to draw overlay window, confirm purchase still goes through Play Store
Power user enables developer options → “Don’t keep activities”Activity recreation does not lose purchase flow stateToggle option, complete purchase, verify token received

---

Manual Testing Approach

A disciplined manual process catches issues that automated scripts might miss, especially those tied to timing, device state, or human perception.

1. Prepare the environment

2. Verify the happy path

  1. Launch the app, navigate to the subscription screen.
  2. Tap Subscribe for a plan that has a test price (e.g., $0.99/month).
  3. In the Play Store sheet, select the pre‑configured test credit card (e.g., “Always succeed”).
  4. Complete the purchase.
  5. Observe the app UI: a confirmation toast or dialog should appear.
  6. Pull the purchase token from logs (adb logcat | grep "Purchase token").
  7. Call your verification endpoint with the token and assert the response contains {"state":"ACTIVE"}.
  8. Verify that premium features are unlocked in the UI.

3. Test error paths

Expect a graceful error dialog and no token.

4. Exercise edge cases

5. Accessibility checks

6. Security & privacy verification

Search the file for any substring that looks like a purchase token (a long base64‑ish string). It should not appear.

No plain token should be present.

7. Performance sanity

8. Cleanup

After each test cycle, clear data or call your backend to acknowledge and revoke the purchase (if you have a test revocation endpoint) to keep the sandbox clean.

---

Automated Testing Approaches

Automation gives you repeatability and lets you run the matrix on every commit. Below are layers you can combine.

Unit layer – mocking BillingClient

Because BillingClient is final, wrap it in an interface (as shown in the repository snippet). Then you can inject a mock.


interface IBillingService {
    fun startConnection()
    fun launchPurchase(activity: Activity, skuDetails: SkuDetails)
    fun acknowledgePurchase(purchaseToken: String)
    fun queryPurchases(): List<Purchase>
}

class BillingServiceImpl @Inject constructor(
    @ApplicationContext private val ctx: Context
) : IBillingService {
    private val client = BillingClient.newBuilder(ctx)
        .setListener { result, purchases -> /* … */ }
        .build()
    // delegate methods …
}

// In ViewModel
class PurchaseViewModel @Inject constructor(
    private val billing: IBillingService
) { /* … */ }

// Test
@Test
fun `purchase success triggers acknowledgment`() {
    val billingMock = mock(IBillingService)
    val viewModel = PurchaseViewModel(billingMock)
    // simulate user click
    viewModel.onSubscribeClicked()
    verify(billingMock).launchPurchase(any(), any())
    // simulate Play Store returning a purchase
    val fakePurchase = Purchase.newBuilder()
        .setPurchaseToken("tok_123")
        .setSku("sub_monthly")
        .setPurchaseState(Purchase.PurchaseState.PURCHASED)
        .build()
    viewModel.onPurchaseUpdated(listOf(fakePurchase))
    verify(billingMock).acknowledgePurchase("tok_123")
}

*Use Mockito or MockK; run with JUnit4/JUnit5 on the JVM (Robolectric optional if you need Android resources).*

Instrumentation layer – Espresso/UIAutomator

You cannot directly interact with the Play Store purchase dialog, but you can verify that your app launches the correct intent and handles the result.


@RunWith(AndroidJUnit4::class)
class PurchaseFlowTest {

    @Test
    fun launchPurchaseFlow_showsConfirmation() {
        // navigate to subscription screen
        onView(withId(R.id.btn_subscribe)).perform(click())

        // verify that the app started the billing flow via a callback
        // We cannot assert Play Store UI, but we can check that a PurchaseUpdatedListener
        // receives a purchase after we mock the BillingClient in the test rule.
        // For end‑to‑end we rely on Firebase Test Lab with a real Play Store account.
    }
}

To test the actual Play Store UI you need a real or managed Google Play environment, which is why services like Firebase Test Lab or Google Play’s internal testing track are used.

Firebase Test Lab + internal test track

  1. Upload your APK/AAB to the internal test track.
  2. Create a test matrix in Firebase Test Lab that selects a range of devices (different API levels, screen sizes, manufacturers).
  3. In the test script (using Espresso or UiAutomator), after launching the purchase flow, use the test credit card provided by Play Console’s license testing to complete the purchase.
  4. After the test finishes, pull the logcat and verify that the purchase token appears and that your backend received a valid verification request.

Sample gcloud command to kick off a test:


gcloud firebase test android run \
    --type instrumentation \
    --app app-debug.apk \
    --test tests-apk.apk \
    --device model=Pixel3,version=28,locale=en,orientation=portrait  \
    --directories-to-pull /sdcard/logcat

End‑to‑end with Appium (optional)

If you need to test a hybrid flow that includes a web view for managing subscriptions (e.g., a web portal), Appium can drive both the native Android app and the web context.


AppiumDriver<MobileElement> driver = new AndroidDriver<>(new URL("http://localhost:4723/wd/hub"), caps);
// navigate to subscription screen
driver.findElement(By.id("btn_subscribe")).click();
// switch to WebView if needed
Set<String> contexts = driver.getContextHandles();
for (String ctx : contexts) {
    if (ctx.contains("WEBVIEW")) {
        driver.context(ctx);
        break;
    }
}
fillInWebForm("//input[@name='cardNumber']", "4000000000000002"); // test card
driver.findElement(By.id("confirm_purchase")).click();
driver.context("NATIVE_APP");
assertTrue(driver.findElement(By.id("purchase_success")).isDisplayed());

Autonomous, persona‑driven exploration (SUSA)

SUSA can be dropped into your CI as an additional step that complements scripted tests. It works by:

During exploration SUSA automatically:

Example of a bug that SUSA found in a real subscription flow (anonymized):

*An *Impatient* agent double‑tapped the subscribe button while the Play Store dialog was still animating. The app launched a second billing flow, resulting in two purchase tokens being generated. The backend only acknowledged the first token, leaving the second in a PENDING state that never resolved, causing the user to be charged twice on renewal.*

Because traditional scripts usually perform a single, linear tap, they never reproduced the race condition. SUSA’s random interleaving uncovered it.

To run SUSA locally:


pip install susatest-agent
susatest run --apk path/to/app.apk \
    --personas curious impatient novice adversarial accessibility poweruser \
    --duration 300   # seconds per persona

The tool outputs a JSON report with PASS/FAIL per flow, plus any discovered regression scripts.

---

Code Examples – Practical Snippets

1. BillingClient lifecycle helper (Kotlin)


object BillingManager {
    private var client: BillingClient? = null

    fun init(context: Context) {
        client = BillingClient.newBuilder(context)
            .setListener { billingResult, purchases ->
                when {
                    billingResult.responseCode == BillingResponseCode.OK -> {
                        purchases?.let { handlePurchases(it) }
                    }
                    billingResult.responseCode == BillingResponseCode.USER_CANCELED -> {
                        // handle cancel
                    }
                    else -> {
                        // handle error
                    }
                }
            }
            .enablePendingPurchases()
            .build()
    }

    fun start() {
        client?.startConnection { result ->
            if (result.responseCode == BillingResponseCode.OK) {
                // ready
            }
        }
    }

    private fun handlePurchases(purchases: List<Purchase>) {
        purchases.forEach { purchase ->
            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                // verify on backend, then acknowledge
                acknowledgeIfNeeded(purchase.purchaseToken)
            }
        }
    }

    private fun acknowledgeIfNeeded(token: String) {
        // call your verification endpoint, then:
        client?.acknowledgePurchase(
            AcknowledgePurchaseParams.newBuilder()
                .setPurchaseToken(token)
                .build()
        ) { /* handle result */ }
    }

    // expose query, launchFlow, etc.
}

*This singleton isolates all billing logic, making it trivial to replace client with a fake in tests.*

2. Fake BillingClient for unit tests (using Mockito)


class FakeBillingClient(
    private val purchases: List<Purchase> = emptyList()
) : BillingClient(ApplicationProvider.getApplicationContext()) {
    override fun startConnection(listener: BillingClientStateListener) {
        // simulate instant success
        listener.onBillingServiceConnected()
    }

    override fun launchBillingFlow(activity: Activity, flowParams: BillingFlowParams) {
        // do nothing – the test will manually invoke the listener
    }

    override fun acknowledgePurchase(
        acknowledgePurchaseParams: AcknowledgePurchaseParams,
        listener: BillingResponseListener
    ) {
        // simulate success
        listener.onBillingResponse(BillingResponse.BillingResponseOK)
    }

    override fun queryPurchasesAsync(
        queryPurchasesParams: QueryPurchasesParams,
        listener: PurchasesUpdatedListener
    ) {
        listener.onPurchasesUpdated(
            BillingResponse.BillingResponseOK,
            purchases
        )
    }
}

*Inject FakeBillingClient into your repository; you can pre‑load it with a purchase that has a known token to test acknowledgment logic.*

3. ADB command to simulate a network drop and restore


# Disable Wi‑Fi
adb shell svc wifi disable
# (user taps buy, wait a few seconds)
# Re‑enable Wi‑Fi
adb shell svc wifi enable

*You can wrap this in a shell script that loops through a purchase flow to verify graceful handling.*

4. Fast‑forwarding trial with Play Console license test

In Play Console → License testing → Add a license test account → set License Response to GRANTED and Validity Timestamp to a future date (e.g., +30 days). The app will treat the user as having an active subscription instantly, letting you test post‑trial UI without waiting.

5. Verifying purchase token absence in logcat (bash)


adb logcat -c
# run your purchase flow
adb logcat -d -s PurchaseManager | grep -i "token" || echo "No token leaked"

*If the grep returns nothing, the token is not present in the filtered log.*

---

Edge Cases That Only Show in Production

ScenarioWhy it’s hidden in dev/testProduction symptomDetection tip
Network handoff (Wi‑Fi → LTE) during purchaseEmulators rarely simulate radio switch; test devices often stay on one network.Purchase flow hangs, user sees indefinite spinner, eventually times out → charge‑back.Use adb shell cmd netcfg to toggle radio state or tools like Network Profiler to simulate latency spikes.
Simultaneous purchases from two devices (same account)Test accounts usually limited to one device; dev environment may not enforce concurrency.Server receives two tokens for same subscription period; leads to duplicate entitlement or conflict errors.Run two emulators logged into the same test account, trigger purchase within a few seconds, verify server deduplicates or handles gracefully.
Rooted device with Xposed/FRIDA hooking Play StoreMost test devices are stock; rooting is uncommon in internal QA.Purchase flow may be tampered with, leading to fraudulent token generation or bypass.Use SafetyNet or Play Integrity API checks; monitor for abnormal token signatures.
Google Play Store version mismatchQA devices often run the latest Play Store; a fraction of users lag behind.Older Play Store may not support new proration modes or may misinterpret developer payload, causing purchase failures.Keep a matrix of Play Store versions in your test farm (via Firebase Test Lab’s playStoreVersion option).
Price change propagation delayLicense test accounts instantly reflect price changes; production propagation can take up to 24 h.Some users see old price UI but are charged new price → confusion and refunds.After updating price, wait and verify via a separate test account that hasn’t been forced to grant license.
Promo code redemption failurePromo codes often require a real payment method; test cards sometimes bypass validation.User enters a valid promo code, gets error “Code not applicable”, loses trust.Test with a real promo code (create in Play Console) using a test account that has a valid test card on file.
Family Library sharingTest accounts rarely belong to a family group.Purchase made by family manager not reflected for member, or vice‑versa.Create a family group in Play Console test environment, add two test accounts, verify entitlement sync.
Subscription pause/resume glitchPause feature is relatively new; few test scenarios cover edge of max pause duration.After max pause, subscription does not auto‑resume, user loses access despite paying.Set pause to the maximum allowed (3 months), wait, then check that state transitions to ACTIVE automatically.
Tax/VAT changes mid‑cycleSandbox does not apply tax calculations.User in a jurisdiction with VAT sees unexpected amount on receipt, leading to support tickets.Use Play Console’s “tax settings” test mode (if available) or rely on backend receipt validation that includes tax fields.
Device language/locale switch during flowTests often set locale once at start.UI strings appear in wrong language, causing mis‑taps (e.g., confusing “Confirm” with “Cancel”).Change locale via adb shell setprop persist.sys.language fr && adb shell setprop persist.sys.country FR && stop && start mid‑flow, ensure labels remain correct.
Background location or battery optimizations killing the Play Services processBattery optimizations are often disabled on test devices.Play Services gets killed, purchase flow never returns result, leaving UI stuck.Enable “Battery optimization → All apps → Your app → Don’t optimize” off, then test with aggressive background restrictions.

---

Accessibility & Localization Checklist

ItemHow to testPass criteria
TalkBack labelsEnable TalkBack, swipe each elementEvery button, checkbox, and field announces a purposeful description
Focus orderUse TalkBack or keyboard navigation (if external keyboard attached)Logical top‑to‑bottom, left‑to‑right order; no traps
Touch target sizeRun Accessibility Scanner or manually measure with UIAutomatorMinimum 48 dp width/height

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free