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
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
- BillingClient – the entry point; you start a connection, listen for updates, and launch purchase flows.
- Product IDs – strings you define in Play Console; for subscriptions they include a base plan ID and optionally an offer tag.
- Purchase token – a unique identifier Google returns after a successful purchase; you must send it to your backend for validation.
- Acknowledgment – required within three days for subscriptions; otherwise Google refunds the transaction.
- Purchase states –
PURCHASED,PENDING,UNSPECIFIED_STATE. Subscriptions also haveSUBSCRIPTION_STATE_ACTIVE,SUBSCRIPTION_STATE_CANCELED,SUBSCRIPTION_STATE_IN_GRACE_PERIOD,SUBSCRIPTION_STATE_ON_HOLD,SUBSCRIPTION_STATE_PAUSED. - Proration modes –
IMMEDIATE_WITH_TIME_PRORATION,IMMEDIATE_AND_CHARGE_PRORATED_PRICE,IMMEDIATE_WITHOUT_PRORATION,DEFERRED. - Obfuscation – the library signs responses with a developer‑provided public key; you verify the signature on your server.
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.
| Category | Sub‑scenario | Expected outcome | Verification method |
|---|---|---|---|
| Happy Path | User launches purchase flow with a valid test card, completes purchase, sees confirmation screen | Purchase token generated, UI shows “Thank you”, backend receives valid purchase, subscription state ACTIVE | Check logs for PurchaseUpdatedListener, call backend verification API, assert UI text |
| User restores purchases after clearing app data | Previously active subscription is restored, UI shows premium features | Query BillingClient.queryPurchasesAsync after data clear, verify restored purchase | |
| Error Paths | Network loss during purchase flow | Flow cancels, user sees error dialog, no purchase token created | Disable Wi‑Fi/mid‑flow via adb shell svc wifi disable, assert error message shown |
| User cancels at the Play Store confirmation screen | Flow returns BillingResponseCode.USER_CANCELED, no token | Observe onBillingResult callback, assert USER_CANCELED | |
| Invalid product ID (typo) | BillingResponseCode.ITEM_UNAVAILABLE | Query non‑existent SKU, verify error code | |
| Declined test card (use Play Store’s “Always fail” test card) | Purchase fails, user sees payment error | Use test card 4000 0000 0000 0002 (always decline) | |
| Edge Cases – Purchase Flow | Rapid double‑tap on buy button | Only 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 open | Dialog survives, purchase completes successfully | Rotate device via adb shell settings put user_rotation 1, then complete flow | |
| App goes to background (home key) during flow | Purchase continues in Play Store; returning to app shows result | Press Home, wait, reopen app, verify purchase result | |
| User has multiple active subscriptions (different tiers) | Each subscription maintains independent token and state | Purchase two different plans, verify both tokens present | |
| Edge Cases – Subscription Lifecycle | Upgrade/downgrade with proration mode IMMEDIATE_AND_CHARGE_PRORATED_PRICE | User gains higher tier immediately, charged prorated amount on next billing date | Simulate upgrade via Play Console test offerings, check backend proration event |
| Free trial conversion to paid | After trial period, first paid charge occurs, subscription stays ACTIVE | Fast‑forward time with adb shell am send-io or use Play Console license test to skip trial | |
| Grace period after payment failure | User retains access for X days, subscription state IN_GRACE_PERIOD | Simulate card decline, wait for grace period, verify access and state | |
| Account hold after grace period expiry | Access blocked, state ON_HOLD | Continue failure, check state transition | |
| Pause subscription (if enabled) | User can pause for 1 week–3 months, access retained during pause | Initiate pause via Play Store, verify SUBSCRIPTION_STATE_PAUSED | |
| Price change notification | User sees dialog about upcoming price change, can accept or cancel | Trigger price change in Play Console, verify dialog appears | |
| Accessibility | TalkBack navigation through purchase flow | All controls have spoken labels, focus order logical | Enable TalkBack, swipe through flow, listen for missing labels |
| Font scaling up to 200% | UI elements not clipped, buttons remain tappable | Set Settings > Accessibility > Font size to largest, test flow | |
| High contrast / dark theme | Text legible, contrast ratios ≥ 4.5:1 | Use developer options to force dark theme, verify with accessibility scanner | |
| Security & Privacy | Purchase token not leaked in logcat | No token appears in adb logcat when flow completes | Run 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 certs | Use adb shell cmd netlog or Charles proxy to inspect | |
| Receipt validation rejects replayed tokens | Re‑submitting same token returns error | Send already‑acknowledged token again to backend, expect failure | |
| App does not store raw purchase data in SharedPreferences unencrypted | No plain‑token persisted locally | Inspect app’s data directory after purchase, verify token absent or encrypted | |
| Performance | Purchase flow latency < 2 s on median device (5 yr old) | Time from button click to Play Store confirmation < 2 s | Use adb shell am start-activity -W to measure launch time, or Android Studio Profiler |
| Memory leak absent after repeated purchase attempts | Heap does not grow unbounded after 20 cycles | Loop purchase/cancel, monitor with adb shell dumpsys meminfo | |
| Regression | After library upgrade (e.g., 4 → 5) all existing flows still work | No new failures introduced | Run full matrix against both versions in a staging environment |
| Persona‑Driven (exploratory) | Curious user taps every visible element before buying | No stray clicks trigger unintended purchases or crashes | Autonomous agent explores UI, logs any unexpected state changes |
| Impatient user spams back button during Play Store dialog | Dialog dismisses cleanly, app returns to prior screen without leaking token | Simulate rapid back presses, verify no purchase created | |
| Elderly user with increased touch tolerance | Long presses are interpreted correctly, no false positives | Adjust 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 flow | Attempt 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 state | Toggle 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
- Create a license test account in Play Console → Settings → License testing. Add your tester’s Google account.
- Enable testing track (internal, closed, or open) and upload a version with
BillingClientset toEnvironment.SANDBOX(or use the built‑in test mode). - Clear app data before each test run:
adb shell pm clear com.example.app
2. Verify the happy path
- Launch the app, navigate to the subscription screen.
- Tap Subscribe for a plan that has a test price (e.g., $0.99/month).
- In the Play Store sheet, select the pre‑configured test credit card (e.g., “Always succeed”).
- Complete the purchase.
- Observe the app UI: a confirmation toast or dialog should appear.
- Pull the purchase token from logs (
adb logcat | grep "Purchase token"). - Call your verification endpoint with the token and assert the response contains
{"state":"ACTIVE"}. - Verify that premium features are unlocked in the UI.
3. Test error paths
- Network loss – enable airplane mode toggle mid‑flow:
adb shell svc wifi disable # disable
# after user taps buy, wait a few seconds then
adb shell svc wifi enable # re‑enable
Expect a graceful error dialog and no token.
- User cancellation – press the back button on the Play Store confirmation screen.
- Invalid SKU – change the product ID in code to a non‑existent string and verify
ITEM_UNAVAILABLE.
4. Exercise edge cases
- Rapid double‑tap – use a script or a second finger to tap twice within 100 ms. Verify only one flow starts.
- Orientation change – while the Play Store dialog is visible, rotate the device:
adb shell settings put user_rotation 1 # landscape
# complete purchase
adb shell settings put user_rotation 0 # portrait
SUBSCRIPTION_UPDATE event with correct proration.ON_HOLD and access is blocked.5. Accessibility checks
- Turn on TalkBack (
Settings > Accessibility > TalkBack). Swipe through each screen of the purchase flow. Every button, checkbox, and text field should announce a meaningful label. - Set Font size to the largest setting and verify no clipping.
- Enable Color correction or Dark theme via developer options and run a contrast analyzer (e.g., Android Accessibility Test Framework) to ensure minimum contrast ratios.
6. Security & privacy verification
- Run the purchase flow with logcat capture:
adb logcat -c # clear
# perform purchase
adb logcat -d > purchase_log.txt
Search the file for any substring that looks like a purchase token (a long base64‑ish string). It should not appear.
- Use a proxy like Charles or Mitmproxy to inspect network traffic. Confirm that calls to your verification endpoint are over TLS and that the developer payload (if you use one) is not exposed in clear text.
- Check the app’s internal storage for leftover purchase data:
adb shell run-as com.example.app ls /data/data/com.example.app/files
adb shell run-as com.example.app cat /data/data/com.example.app/shared_prefs/*.xml
No plain token should be present.
7. Performance sanity
- Use Android Studio Profiler to record CPU and memory while repeating the purchase flow 10 times. Look for spikes or steady growth.
- Measure end‑to‑end latency with
adb shell am start-activity -W -n com.example.app/.PurchaseActivity. The “TotalTime” field should be under 2000 ms on a mid‑tier device (e.g., Pixel 4a).
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
- Upload your APK/AAB to the internal test track.
- Create a test matrix in Firebase Test Lab that selects a range of devices (different API levels, screen sizes, manufacturers).
- 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.
- 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:
- Installing the APK on a device or emulator.
- Launching the app and then letting a set of persona agents explore the UI for a configurable time (e.g., 5 minutes per persona).
- Each agent follows a behavior profile:
- *Curious* – taps every visible element, long‑presses, scrolls randomly.
- *Impatient* – performs rapid actions, spams back button, tries to skip screens.
- *Novice* – follows suggested UI hints, takes longer pauses.
- *Adversarial* – injects overlay windows, attempts to click outside bounds, uses accessibility services to hijack focus.
- *Accessibility* – enables TalkBack, changes font size, uses switch access.
- *Power user* – toggles developer options, uses USB keyboard shortcuts, rotates device aggressively.
During exploration SUSA automatically:
- Detects crashes, ANRs, and unhandled exceptions via logcat monitoring.
- Records every UI transition and flags dead ends (screens where no further action is possible).
- Checks for purchase‑related anomalies: multiple purchase flows launched simultaneously, purchase token appearing in logs, UI elements that remain enabled after a purchase, or purchase dialogs that never dismiss.
- Generates a regression script (Appium + Playwright) for any flow it discovered, so you can add those scenarios to your automated suite.
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
| Scenario | Why it’s hidden in dev/test | Production symptom | Detection tip |
|---|---|---|---|
| Network handoff (Wi‑Fi → LTE) during purchase | Emulators 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 Store | Most 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 mismatch | QA 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 delay | License 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 failure | Promo 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 sharing | Test 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 glitch | Pause 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‑cycle | Sandbox 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 flow | Tests 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 process | Battery 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
| Item | How to test | Pass criteria |
|---|---|---|
| TalkBack labels | Enable TalkBack, swipe each element | Every button, checkbox, and field announces a purposeful description |
| Focus order | Use TalkBack or keyboard navigation (if external keyboard attached) | Logical top‑to‑bottom, left‑to‑right order; no traps |
| Touch target size | Run Accessibility Scanner or manually measure with UIAutomator | Minimum 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