Back to Tutorials
Last edited September 4, 2026
September 4, 2026
6 min read
Intermediate
Engineering Patterns

Testing the States That Break Mobile Apps

A production testing matrix for React Native apps: installation, authentication, offline behavior, purchases, lifecycle changes, and data migrations.

Most mobile bugs do not appear in a clean simulator with a newly created account and reliable Wi-Fi. They appear after an update, on a stale session, while a purchase is pending, after a request loses its response, or when the operating system resumes an app hours later. Testing those states is product engineering, not a final checklist.

Build a state matrix before writing more tests

List the states a person can actually enter, the transition that creates each state, and the expected user-visible result. This compact matrix is a useful starting point:

AreaStates worth exercisingWhat to verify
Installationfresh install, return, update, reinstall, stale/corrupt local statebootstrap, defaults, migration, safe recovery
Authenticationsigned out, signed in, expired/revoked session, deleted account, provider cancellation/failureroute guards, refresh, error copy, local cleanup
Networkoffline launch, disconnect mid-request, reconnect, slow response, timeout, partial backend failureusable cached state, retry and honest status
Purchasesnew purchase, cancellation, restore, existing/expired entitlement, store unavailableno false downgrade; recoverable UI
Lifecycleforeground/background, killed during work, resume after days, memory pressurepersisted state and resumed work
Versionsold schema, new build, partial migrationordered migration and no silent data loss

The matrix changes with the product. A fully local app has no cloud-sync conflict state; an account-backed app does. The goal is not exhaustive combinations. It is coverage of every transition that could lose work, lock someone out, charge them incorrectly, or make the app misleading.

Test at the right layer

Unit tests are fast feedback for pure parsing, reducers, selectors, migration transforms, and state transitions. They should not claim to prove a native authentication redirect or a store purchase.

Integration tests connect the pieces: a session listener changes navigation, a failed request creates a retryable pending operation, a migration transaction updates a schema version, or an entitlement update changes a feature gate. Use deterministic fixtures and controllable clocks/network responses. A test account that can be destroyed or reset is far safer than using a personal account.

Manual device tests catch the integration boundaries the test runtime cannot reproduce. Use development builds while integrating native modules, then preview/release-like builds for behavior that depends on signing, bundle configuration, redirects, notifications, or store services. TestFlight/internal testing is a separate release stage, not a synonym for a simulator run.

Installation and persisted state

Fresh install tests the startup path. Returning install tests restoration. Update tests an old persisted schema against the new binary. Reinstall tests the product's real recovery promise: local data may be gone, restored through OS backup, or recovered from an account, depending on the architecture. Never imply those are equivalent.

Create fixture databases for prior versions and run every migration from each supported version. Simulate an interruption after one migration step. For derived cache data, deleting and rebuilding can be safer than repairing; for user-created data, an explicit backup or transactional migration is usually needed. The architecture and migration rationale are expanded in Building an Offline-First React Native App.

Authentication is a lifecycle, not a boolean

Exercise signed-out startup, restored sign-in, expired token refresh, a revoked session, provider cancellation, and a deleted account. UI should wait for auth initialization rather than briefly rendering the wrong route. On logout or deletion, verify user-scoped local data is cleared or partitioned so the next user of the device cannot see it.

Run provider flows on actual devices and real release configuration. Redirect schemes, Apple credentials, and browser handoff behave differently from a mocked callback. Apple and Google Sign-In with Expo and Supabase explains why each build profile needs its own redirect verification; the account-deletion workflow adds a destructive-operation test case.

Network failure has several shapes

Airplane mode at launch checks local availability. Disconnecting during a request checks cancellation and durable pending work. A timeout checks the ambiguous case where the server may have completed the work. Reconnection checks retry order and conflict handling. A 500 response, DNS failure, authorization error, and validation error are not interchangeable—only transient cases should be automatically retried.

For an offline-capable screen, assert what it can still show and what status it communicates. Avoid treating reachability as proof that the backend works. Test slow responses too: a spinner must not block a previously useful screen, and repeat taps must not duplicate a command.

Purchases and lifecycle work outside your screen

Test a new purchase, a cancellation, restore on a new/reinstalled device, an entitlement that expires, and temporary store failure. A cancelled purchase is an expected outcome, not a crash dialog. Do not downgrade a paying user merely because the latest entitlement fetch failed while offline. The RevenueCat + Expo tutorial covers entitlement state, restore, sandbox behavior, and server authority.

During lifecycle testing, background the app mid-operation, force-close it, then relaunch. Resume after hours or days with an expired session and changed network. Treat memory pressure as a reason that any in-memory promise or navigation state may disappear; durable state should be recoverable from disk or the server.

Make diagnosis safe

Log state transitions, request IDs, build version, migration version, and coarse error categories that help diagnose a problem. Do not log access tokens, passwords, purchase receipts, full request bodies, or unnecessary personal data. A useful log makes a failure reproducible without creating a second privacy incident.

A release-focused loop

Turn incidents into fixtures

When a defect comes from an unusual state, capture the smallest reproducible fixture: app version, persisted-data version, account state, network condition, action sequence, and expected recovery. A vague test called “handles offline” tends to regress; a fixture named “migration from v3 with pending write and expired session” tells future maintainers what must remain true.

Release testing should also distinguish test environments from production behavior. Sandbox purchases, provider test credentials, preview APIs, and development redirect schemes are valuable, but each can conceal a configuration difference. Keep a concise release checklist that identifies which rows must be run against a signed internal build and which must be repeated in TestFlight or the equivalent store channel. If an operation changes server data, verify the server-side result too; a green client toast is not evidence that the intended state was persisted.

The matrix should influence design early. If an envisioned feature has no credible answer for “what if the app dies here?” or “what does this look like offline?”, either simplify it or create the missing durable state before polishing its happy path.

Before release, run automated checks, execute the highest-risk matrix rows on real devices, and record the exact build plus test-account state. Add a regression test whenever a production issue exposes a state you did not model. That is how testing becomes cumulative engineering knowledge rather than a ritual before the store upload. For distribution steps, see Shipping an Expo App to the App Store with EAS.