How to Test Refund Flow on Android (Complete Guide)

Refunds are a critical touchpoint between a user and an app’s business logic. When a user initiates a refund, the system must reverse a financial transaction, update inventory or entitlements, emit th

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

Why Refund Flow Testing Matters

Refunds are a critical touchpoint between a user and an app’s business logic. When a user initiates a refund, the system must reverse a financial transaction, update inventory or entitlements, emit the correct notifications, and persist an audit trail that satisfies both internal finance teams and external regulators. A failure in any of these steps can lead to:

Because refunds often involve multiple services (payment gateway, order service, inventory, notification, and analytics), they are prone to integration bugs that only surface under specific conditions such as poor network, race conditions, or unusual user behavior. A dedicated test effort that covers happy paths, error paths, and edge cases reduces the chance that these issues reach production.

Common Failure Points in Production

Understanding where refunds break helps prioritize test cases. The following patterns appear repeatedly in post‑mortems across Android apps that handle in‑app purchases, subscription cancellations, or e‑commerce returns.

Failure CategoryTypical SymptomRoot Cause
Payment gateway mismatchRefund amount differs from original chargeGateway returns a different currency or applies fees not mirrored in app state
State desyncUI shows “Refunded” but backend still marks order as “Pending”Async callback not handled or missed due to process kill
Missing receipt validationRefund processed without verifying purchase tokenTrusting client‑side data; attacker can replay old token
Accessibility blockTalkBack users cannot reach the “Confirm Refund” buttonButton lacks content‑description or is hidden behind a modal
Network retry loopIndefinite spinner after tapping refund due to 504 errorsNo exponential backoff or user‑visible error state
Localization glitchRefund amount displays with wrong decimal separatorHard‑coded format string instead of using NumberFormat.getCurrencyInstance()
Security leakageRefund screen logs full payment token in LogcatDebug logging left enabled in release builds
Race conditionRapid double‑tap creates two refund requestsNo idempotency key or UI disabling during request

These categories inform the test matrix that follows. By mapping each symptom to a concrete test condition, you can verify that the app’s defensive measures (idempotency tokens, receipt verification, UI locks, accessibility labels, proper error handling) are present and functional.

Test Matrix Overview

A structured matrix ensures coverage across functional, non‑functional, and persona‑driven dimensions. Below is a comprehensive table that you can copy into a test‑management tool or spreadsheet. Each row represents a test scenario; columns indicate the dimension being validated.

Functional Test Matrix

IDScenarioPreconditionsStepsExpected ResultPass/Fail Criteria
F1Happy‑path refund for consumable IAPUser owns a valid consumable item, network stable1. Open app → Store → My Purchases → Select item → Tap “Refund” → Confirm in dialogRefund succeeds, item removed from inventory, gateway returns SUCCESS, UI shows “Refunded” toast, analytics event refund_success loggedVerify item gone, receipt invalidated, correct amount returned, no error dialogs
F2Refund with insufficient funds (gateway returns INSUFFICIENT_FUNDS)Same as F1, but gateway mocked to return errorSame steps as F1Error dialog shown, UI retains item, no analytics success event, error loggedConfirm UI shows retry option, state unchanged, no financial side‑effect
F3Network loss during refund requestSame as F1, enable airplane mode after tapping ConfirmSame steps as F1App shows “Network error” toast, retains item, offers retry, no duplicate request sentVerify only one network call logged, UI not stuck in loading
F4Duplicate tap protectionSame as F1, user taps Confirm twice quicklySame steps as F1Only one refund request sent, second tap ignored or shows “Already processing”Check request count via proxy, UI disabled after first tap
F5Partial refund (e.g., shipping fee non‑refundable)Order includes item + shipping, policy states shipping non‑refundableSame steps as F1, but select “Refund item only”Refund amount equals item price, shipping fee retained, UI shows correct breakdownVerify amount matches expectation, order status reflects partial refund
F6Refund after app upgrade (migration)User had purchased item on version 1.0, app upgraded to 2.0 with new purchase schemaLaunch upgraded app, navigate to purchase list, attempt refundRefund works as if purchase token still valid, migration script correctly maps old tokenConfirm token mapping, no crash, correct UI
F7Refund cancellation by userSame as F1, user taps “Cancel” in confirmation dialogSame steps as F1 until dialog, then tap CancelNo request sent, UI returns to purchase screen, item unchangedVerify no network call, state unchanged
F8Refund with expired purchase token (simulated)Token expires after 24h in sandbox, wait >24h then attempt refundSame steps as F1Gateway returns TOKEN_EXPIRED, app shows “Purchase not found” error, no state changeVerify error handling, no crash
F9Refund triggering inventory restockItem is inventory‑tracked, stock count = 0 before refundSame steps as F1After refund, stock count increments by 1, backend reflects updateCheck inventory API or DB entry
F10Refund receipt verification failureTampered receipt sent to backendSame steps as F1, but modify receipt signatureBackend rejects refund, UI shows error, no financial changeVerify signature validation logs, UI error

Non‑functional & Persona Matrix

IDScenarioPersonaPreconditionsStepsExpected Result
N1Screen‑reader navigationAccessibility (TalkBack) userTalkBack enabled, refund screen loadedSwipe to locate “Refund” button, double‑tap to activateButton announces label and state, activation works
N2Color‑blind contrastLow‑vision userDeveloper options → Simulate color blindnessObserve refund screenAll actionable elements meet WCAG AA contrast (≥4.5:1)
N3Large font scalingElderly userSystem font size set to 200%Navigate to refund flowText scales, UI elements not clipped, buttons remain tappable
N4Impatient user (rapid taps)Power‑userDevice performance normalTap Refund button 5 times within 2 secondsUI prevents multiple requests, shows single in‑progress indicator
N5Adversarial input (SQLi attempt)Security‑focused testerBurp Suite proxy configured to intercept refund APIInject ' OR 1=1 -- into any free‑text field (e.g., notes)Backend sanitizes input, returns validation error, no DB error
N6Curious user (exploratory)NoviceNo prior knowledge of refund policyWander through menus, tap random icons, eventually find refundFlow discoverable within ≤3 taps from home screen, clear scent
N7Novice user (guidance needed)First‑time buyerApp shows tutorial on first launchFollow tutorial prompts to request refundTooltips guide user to correct screen, no confusion
N8Elderly user with tremorMotor‑impairmentEnable “Touch accommodation” (iOS‑like) via accessibility serviceAttempt to tap small “Confirm” buttonButton has sufficient hit‑target (≥48dp) or provides alternate larger target
N9Privacy‑conscious userPrivacy advocateApp requests permission to read SMS for OTP (if used)Decline permission, attempt refundRefund proceeds without SMS permission, or explains why permission is needed
N10Localization (right‑to‑left)Arabic/Hebrew userSystem language set to ArabicNavigate to refund screenLayout mirrors, text aligned correctly, no overlapping

These tables give you a concrete starting point. In practice, you would expand each ID with detailed test data (amounts, currencies, gateway mock responses) and assign owners for execution.

Manual Testing Approach

Even with strong automation, manual exploratory testing remains indispensable for refund flows because it captures nuances that scripts assume away—such as the feel of a button, the clarity of an error message, or the way a user reacts to a delayed response.

1. Environment Setup

2. Step‑by‑Step Manual Test Script (Happy Path)

  1. Launch the app from the home screen.
  2. Log in (if required) using a test account that has a known consumable purchase.
  3. Navigate to Store → My Purchases. Verify the purchased item appears with correct price and purchase date.
  4. Tap the item to open its detail screen.
  5. Locate the Refund button (should be labeled “Request Refund” or similar).
  6. Long‑press the button to confirm TalkBack reads the label and state (if accessibility testing).
  7. Tap the button once. A confirmation dialog appears with title “Refund Request” and body “Are you sure you want to refund $X.XX?”.
  8. Verify the dialog contains a primary action labeled “Refund” and a secondary action labeled “Cancel”.
  9. Tap Refund.
  10. Observe UI: a progress spinner should appear, the button should be disabled, and no further taps should be accepted.
  11. Wait for the network call to complete (watch logcat for HTTP 200 from gateway).
  12. Upon success, the spinner disappears, a toast “Refund processed” shows, the item disappears from the list, and an analytics event refund_success is logged.
  13. Verify the backend: call your order service API (or check the DB) to confirm the order status is REFUNDED and the refund amount matches the original purchase.
  14. Perform a negative check: try to refund the same item again; the UI should either hide the button or show an error “Already refunded”.

3. Observables to Capture

ObservableTool/MethodAcceptance
Network request count & payloadadb logcat + grep or mitmproxyExactly one POST to /refund with correct amount, currency, and idempotency token
UI state changesEspresso‑style manual observation or UI Automator scriptButton disabled during request, re‑enabled on error/completion
Toast/Snackbar messagesVisual inspectionCorrect spelling, duration ≥2s, disappears automatically
Analytics fireFirebase DebugView or custom loggerrefund_success event with parameters amount, currency, item_id
Accessibility labelsTalkBack feedbackAll actionable elements have contentDescription that describes purpose
Error handlingSimulated gateway errorsApp shows user‑friendly message, does not crash, state unchanged
Security leakagelogcat filter for tokenNo raw payment token or receipt appears in logs (except maybe hashed)

4. Manual Edge‑Case Checks

5. Post‑Test Cleanup

Manual testing gives you confidence that the flow feels right, but it is time‑consuming and prone to human omission. The next section shows how to supplement it with automation that can be run on every commit.

Automated Testing Approaches

Automation accelerates regression detection and enables continuous validation of refund logic. On Android, you have several layers to consider: unit tests for pure logic, instrumentation tests for UI behavior, and end‑to‑end tests that interact with real or mocked services. Below we break down each layer, provide concrete code snippets, and discuss tooling choices.

1. Unit Testing the Refund Use‑Case

Isolate the business rule that decides whether a refund is allowed, builds the request payload, and handles the gateway response. Use JUnit5 with Mockito for dependencies.


// RefundUseCaseTest.kt
class RefundUseCaseTest {

    private lateinit var repo: MockPurchaseRepository
    private lateinit var gateway: MockPaymentGateway
    private lateinit var useCase: RefundUseCase

    @BeforeEach
    fun setUp() {
        repo = mock()
        gateway = mock()
        useCase = RefundUseCase(repo, gateway)
    }

    @Test
    fun `happy path builds correct request and updates state`() {
        // Given a valid purchase
        val purchase = Purchase(
            id = "p_123",
            sku = "cons_001",
            amountMicros = 499_0000, // $4.99
            currency = "USD",
            state = PurchaseState.OWNED
        )
        `when`(repo.getPurchase("p_123")).thenReturn(purchase)
        `when`(gateway.refund(any())).thenReturn(RefundResult.Success)

        // When
        val result = useCase.execute("p_123")

        // Then
        assertTrue(result.isSuccess)
        verify(gateway).refund(argThat {
            it.purchaseId == "p_123" &&
            it.amountMicros == 499_0000 &&
            it.currency == "USD" &&
            it.idempotencyKey.isNotBlank()
        })
        verify(repo).updatePurchaseState("p_123", PurchaseState.REFUNDED)
    }

    @Test
    fun `gateway insufficient funds leads to error state`() {
        val purchase = Purchase(
            id = "p_456",
            sku = "cons_002",
            amountMicros = 999_0000,
            currency = "USD",
            state = PurchaseState.OWNED
        )
        `when`(repo.getPurchase("p_456")).thenReturn(purchase)
        `when`(gateway.refund(any())).thenReturn(RefundResult.Failure(
            errorCode = "INSUFFICIENT_FUNDS",
            message = "Insufficient funds in merchant account"
        ))

        val result = useCase.execute("p_456")

        assertFalse(result.isSuccess)
        assertEquals("INSUFFICIENT_FUNDS", result.errorCode)
        verify(repo, never()).updatePurchaseState(any(), any())
    }
}

*Key points*

Run unit tests with Gradle:


./gradlew testDebugUnitTest

2. Instrumentation UI Tests with Espresso

Espresso validates that the UI reacts correctly to user gestures and that the underlying view model or presenter processes outcomes as expected. Use IdlingResource to wait for network calls.


// RefundFlowTest.kt
@RunWith(AndroidJUnit4::class)
class RefundFlowTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    private val idlingResource = object : IdlingResource {
        private var callback: IdlingResource.ResourceCallback? = null
        override fun getName() = "NetworkIdlingResource"
        override fun isIdleNow(): Boolean = !NetworkMonitor.isRequestInFlight()
        override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
            this.callback = callback
        }
    }

    @Before
    fun registerIdling() {
        IdlingRegistry.getInstance().register(idlingResource)
    }

    @After
    fun unregisterIdling() {
        IdlingRegistry.getInstance().unregister(idlingResource)
    }

    @Test
    fun refundHappyPath_showsSuccessToast_andRemovesItem() {
        // Seed a purchase via dependency injection or fake repository
        TestUtil.seedPurchase(activityRule.scenario, Purchase(
            id = "p_seeded",
            sku = "cons_003",
            amountMicros = 199_0000,
            currency = "USD",
            state = PurchaseState.OWNED
        ))

        // Open My Purchases
        onView(withId(R.id.nav_my_purchases)).perform(click())
        onView(withText("cons_003")).perform(click())

        // Request refund
        onView(withId(R.id.btn_refund)).perform(click())
        onView(withId(R.id.btn_confirm_refund)).perform(click())

        // Wait for network idle, then check toast
        onView(withText(containsString("Refund processed"))).inRoot(ToastMatcher())
            .check(matches(isDisplayed()))

        // Item should be gone
        onView(withText("cons_003")).check(doesNotExist())
    }

    @Test
    fun refundNetworkError_showsRetryOption() {
        TestUtil.seedPurchase(activityRule.scenario, Purchase(
            id = "p_err",
            sku = "cons_004",
            amountMicros = 299_0000,
            currency = "EUR",
            state = PurchaseState.OWNED
        ))

        onView(withId(R.id.nav_my_purchases)).perform(click())
        onView(withText("cons_004")).perform(click())
        onView(withId(R.id.btn_refund)).perform(click())
        onView(withId(R.id.btn_confirm_refund)).perform(click())

        // Simulate network failure via MockWebServer
        MockWebServerUtil.enqueueFailure(HttpURLConnection.HTTP_GATEWAY_TIMEOUT)

        onView(withText(containsString("Network error"))).inRoot(ToastMatcher())
            .check(matches(isDisplayed()))
        onView(withId(R.id.btn_retry)).check(matches(isEnabled()))
    }
}

*Explanation*

Run instrumentation tests on a device or emulator:


./gradlew connectedAndroidTest

3. End‑to‑End Tests with Appium (Real Device / Emulator)

Appium drives the actual Android UI, making it suitable for verifying that the whole stack—including native dialogs, system overlays, and inter‑app communication (e.g., Google Play Billing flow)—behaves as expected. Below is a Python‑based Appium script that mirrors the happy path and also tests an error case.


# test_refund_flow.py
import time
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy

def get_driver():
    options = UiAutomator2Options()
    options.set_platform_name("Android")
    options.set_device_name("emulator-5554")
    options.set_app_package("com.example.app")
    options.set_app_activity(".MainActivity")
    options.set_automation_name("UiAutomator2")
    return webdriver.Remote("http://127.0.0.1:4723/wd/hub", options=options)

def test_refund_happy_path():
    driver = get_driver()
    try:
        # Assume user already logged in and has a purchase
        driver.find_element(AppiumBy.ACCESSIBILITY_ID, "My Purchases").click()
        time.sleep(1)
        driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR,
                            'new UiSelector().text("cons_005")').click()
        driver.find_element(AppiumBy.ID, "com.example.app:id/btn_refund").click()
        driver.find_element(AppiumBy.ID, "com.example.app:id/btn_confirm_refund").click()

        # Wait for success toast
        success_toast = webdriver.WebDriverWait(driver, 15).until(
            lambda d: d.find_element(AppiumBy.XPATH,
                                     "//android.widget.Toast[contains(@text,'Refund processed')]"))
        assert success_toast.is_displayed()

        # Verify item removed
        assert len(driver.find_elements(AppiumBy.ANDROID_UIAUTOMATOR,
                                        'new UiSelector().text("cons_005")')) == 0
    finally:
        driver.quit()

def test_refund_insufficient_funds():
    driver = get_driver()
    try:
        driver.find_element(AppiumBy.ACCESSIBILITY_ID, "My Purchases").click()
        time.sleep(1)
        driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR,
                            'new UiSelector().text("cons_006")').click()
        driver.find_element(AppiumBy.ID, "com.example.app:id/btn_refund").click()
        driver.find_element(AppiumBy.ID, "com.example.app:id/btn_confirm_refund").click()

        # Mock server returns 402 Payment Required
        error_toast = webdriver.WebDriverWait(driver, 15).until(
            lambda d: d.find_element(AppiumBy.XPATH,
                                     "//android.widget.Toast[contains(@text,'Unable to process refund')]"))
        assert error_toast.is_displayed()
        # Item still present
        assert len(driver.find_elements(AppiumBy.ANDROID_UIAUTOMATOR,
                                        'new UiSelector().text("cons_006")')) == 1
    finally:
        driver.quit()

Setup

Run the script:


pip install appium pytest
pytest test_refund_flow.py

4. Leveraging SUSA for Autonomous, Persona‑Driven Exploration

SUSA (susatest.com) can be pointed at your APK or a internal test build and will automatically exercise the refund flow using its built‑in personas. Unlike scripted tests, SUSA does not know the exact UI hierarchy ahead of time; it discovers screens by interacting with the app, applies each persona’s behavior profile, and logs any anomalies it encounters.

How to invoke SUSA for refund testing


# Install the agent (once)
pip install susatest-agent

# Point SUSA at your debug APK
susatest run \
    --apk path/to/app-debug.apk \
    --mode exploratory \
    --personas curious impatient novice adversarial elderly accessibility power_user \
    --target-flow refund \
    --output-dir ./susa-reports/refund \
    --max-depth 6 \
    --timeout-per-action 2s

*What SUSA does*

  1. Entry point detection – It launches the app and begins tapping, scrolling, and typing based on the selected personas.
  2. Flow recognition – Using heuristics (e.g., presence of a “Refund” button, text containing “refund”, or a navigation path that matches known purchase screens), SUSA tags screens that belong to the refund flow.
  3. Persona variation
  1. Result reporting – After the run, SUSA emits a JSON report with PASS/FAIL verdicts per detected flow, screenshots of failure states, and a list of discovered dead ends (e.g., a button that leads to a blank screen).

Why this catches bugs scripts miss

You can schedule SUSA runs nightly on your CI pipeline to complement your unit and instrumentation suites, gaining confidence that the refund flow remains robust under real‑world usage patterns.

Edge Cases and Production‑Only Bugs

Even with exhaustive test matrices, certain defects only manifest after the app reaches real users. Below are categories of production‑only refund bugs, their typical triggers, and concrete strategies to detect them early.

4.1. Network Flakiness & Partial Failures

*Problem*: The refund request succeeds at the gateway, but the response is delayed or gets corrupted, causing the client to timeout while the server has already recorded the refund. The app may then show an error and allow the user to retry, leading to a double refund.

*Detection*:

*Mitigation*:

4.2. Timezone & Currency Conversion Issues

*Problem*: A user in New Zealand purchases an item priced in USD. The refund is processed in the merchant’s local timezone (UTC‑5) but the app displays the amount using the device’s timezone, leading to a mismatch in the shown date/time or an incorrect conversion if the app mistakenly applies a FX rate twice.

*Detection*:

*Mitigation*:

4.3. Race Conditions During App Updates

*Problem*: A user has a pending refund request when an OTA update installs. The update process kills the app; upon restart, the app may resend the refund request because the in‑flight flag was lost, or it may show a stale UI that still says “Processing…” while the refund already completed.

*Detection*:

*Mitigation*:

4.4. Partial Refunds & Mixed Payment Methods

*Problem*: An order contains a subscription (recurring) and a one‑time add‑on purchased via a different payment method (e.g., PayPal). The refund policy states that only the add‑on is refundable, but the backend incorrectly attempts to refund the subscription, causing a service interruption.

*Detection*:

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