Engineering leadership · Android · Release quality
Shipping Android Apps Safely: A Release Validation Strategy for Testing, Rollout and Experiments
When a team asks me “should we A/B test this?”, the real question is usually bigger: how do we know this release is safe, and how do we know it is worth shipping? Those are different questions, and no single kind of test answers both. This article is how I think about it as an engineer, architect and manager: which risks a release carries, which test is the cheapest way to retire each one, who owns it, and what the exit criteria are. A/B testing is one tool in that set, and not the first one you reach for.
- Test to retire risk, not to tick boxes. Name the risk (it crashes, it is slow, it is wrong, nobody wants it), then pick the cheapest test that retires it.
- Two families of question: “is it safe and correct?” (automated tests, dogfooding, internal and closed testing, UAT, staged rollout) and “is it valuable?” (A/B tests, listing experiments, fake doors).
- The audience grows at every gate and the cost of a defect rises with it. Find problems as early and as cheaply as possible, and make every later gate smaller by passing the earlier ones.
- Architecture decides how safely you can ship. Feature flags, a kill switch, backward-compatible APIs, observability and a single release artifact do more than any extra test phase.
- Every gate needs an owner, an entry condition, an exit condition and a time box. A gate without those is a meeting.
Contents
- Start from risk, not from test types
- The map: safe, correct, valuable
- Layer 1: automated tests the team owns
- Layer 2: the non-functional checks people forget
- Layer 3: humans before the market: dogfooding, internal, closed, UAT
- Layer 4: production safety: staged rollout and observability
- Layer 5: proving value: A/B testing and other experiments
- The architecture that makes this cheap
- Tooling on Android
- Running it as a team: ownership, gates, metrics
- Anti-patterns I keep seeing
- Takeaways and glossary
1. Start from risk, not from test types
Teams often copy a process: “we do QA, then UAT, then release.” That treats testing as a ritual. I prefer to ask what could go wrong with this change, how likely it is, and how bad it would be. A copy change in a settings screen and a rewrite of the payment flow do not deserve the same process.
| Risk | Example on Android | Cheapest test that retires it |
|---|---|---|
| It is broken | Logic bug, crash on rotation, null from an API | Unit, integration and UI tests in CI |
| It breaks on real devices | OEM skin quirks, old API level, low memory | Device-lab runs, pre-launch report, closed testing |
| It is slow or heavy | Cold start +400 ms, jank, battery drain, APK size | Macrobenchmark, profiling, Android vitals thresholds |
| It breaks existing users | Database migration fails, login lost after update | Upgrade-path tests from the last N versions |
| It is insecure or non-compliant | Token in logs, exported component, privacy form wrong | Static analysis, security review, Play policy check |
| It does the wrong thing | Meets the ticket, misses the business rule | UAT with the people who own the rule |
| It is unusable in practice | Confusing flow, text cut off in German, no TalkBack labels | Dogfooding, usability sessions, accessibility and locale checks |
| It fails only at scale | Backend load, rare crash at 0.3% of sessions | Staged rollout with live monitoring |
| Nobody wants it | A feature that does not move retention or revenue | A/B test, fake door, listing experiment |
Notice that A/B testing appears in only the last row. If you reach for it to find bugs, you are using real users as your QA department.
2. The map: safe, correct, valuable
I group validation into five layers. Each layer has a bigger audience, more realism and less control than the one before.
3. Layer 1: automated tests the team owns
This is the foundation and it belongs to the engineers who write the code, not to a separate QA phase. I keep the rules simple:
- Many fast unit tests for business logic (use cases, view models, mappers) on the JVM. These run in seconds.
- Fewer integration tests around the boundaries: Room with a real in-memory database, Retrofit against a fake server, WorkManager with a test driver.
- A small set of UI tests for the critical journeys only: sign-in, the main purchase path, onboarding. Flaky end-to-end suites are a tax; keep them few and ruthlessly stable.
- Contract tests between the app and the backend. Most “mobile bugs” I have debugged were an API quietly changing shape.
- Regression test for every escaped bug. It is the best return on testing time you will get.
The practical detail on frameworks, test doubles and the pyramid is in Testing Android apps. The architecture article Testing and proof explains why a testable architecture makes this layer cheap.
4. Layer 2: the non-functional checks people forget
Features that pass every functional test still hurt users. These checks are where I find most of the “why did nobody notice?” incidents.
| Check | What I look at | How |
|---|---|---|
| Performance | Cold and warm start, frame time, memory, battery, app size | Macrobenchmark and Baseline Profiles in CI, with a budget that fails the build |
| Compatibility | Oldest and newest supported API, small and foldable screens, low-RAM devices | Emulators for breadth, a few real devices for truth, Firebase Test Lab for the long tail |
| Upgrade and migration | Update from each of the last few versions with real-shaped data | Install old APK, create data, update, verify. Database migration tests |
| Security and privacy | Exported components, secrets in the APK, cleartext traffic, data-safety form accuracy | Lint and static analysis, dependency scanning, a threat-model review for risky features |
| Accessibility | TalkBack labels, touch target size, contrast, font scaling | Accessibility Test Framework checks in UI tests, a manual TalkBack pass |
| Localization | Long strings, right-to-left, date and currency formats | Pseudo-locales, screenshot tests in several locales |
| Resilience | Offline, slow network, process death, permission denied | Network throttling, “don’t keep activities”, failure-injection in fakes |
| Backend compatibility | Old app versions still alive against the new API | Keep an N-version compatibility suite on the backend; never break a live contract |
Assign each check a threshold, not just a task: “cold start p90 under 1.5 s” is a gate, “look at performance” is a wish.
5. Layer 3: humans before the market
Automation checks what we thought of. People find what we did not. This layer is a sequence, each step with a larger and less friendly audience.
Dogfooding
Employees use the in-progress app for real tasks, not scripts. It finds the slow screens, confusing wording and battery drain that only long use reveals. I make it work with three things: an automatic nightly or per-merge build to everyone in the company, a one-gesture feedback button that attaches logs and device info, and a rule that a blocking issue found by dogfooders stops the release train. The caveat: employees know the product too well, so dogfooding is not a substitute for outside users.
Internal testing
This is the gate right after the build exists. A named team runs a short, written smoke checklist on the exact release candidate: install, update from the previous version, sign in, three critical flows, push, deep link. The Play Console internal track suits this since builds reach testers within minutes. The output is one sentence: “build 4123 may continue” or “it may not, because…”
Closed and open beta
Invite real outsiders: partners, power users, one customer segment. You get device and network diversity you cannot buy. Ask them targeted questions, otherwise feedback is noise. Use open beta only when you need scale and can tolerate public exposure of unfinished work.
UAT
User acceptance testing is not about code defects; it is a business decision. The people who asked for the capability, or who represent the customer, run scenarios in their own words against a staging environment with realistic data, and either accept, accept with known issues, or reject. I insist on four things: written scenarios agreed before the build is ready, a named approver, the build number being tested, and a deadline. UAT that drifts for weeks is a sign that requirements were unclear at the start, not that more testing is needed.
QA / engineering asks “did we build it right?” UAT / product asks “did we build the right thing?” Experiments / product + data ask “of the right things, which is better for users?”
6. Layer 4: production safety: staged rollout and observability
No pre-release test matches production. So the last pre-market gate is production itself, entered carefully. On Google Play this is a staged rollout: release to 1%, then 5%, 20%, 50%, 100%, and watch.
- Pre-agree the gates. For example: crash-free users must stay above 99.5%, ANR below the Play “bad behavior” threshold, checkout success not down by more than 1%. Whoever is on call can halt without asking.
- Watch the cohort, not the average. Compare the new version against the old one on the same days. Crashlytics and Android vitals both break results down by version.
- Make halting cheap. Halting a Play rollout stops new installs of that version, but people who already updated stay on it. That is why a remote kill switch (a flag that disables the new behavior) is part of the design, not an extra.
- Plan the rollback. You cannot truly roll back an Android release. You ship a fix forward, or flip a flag. Know which, and how long each takes.
7. Layer 5: proving value: A/B testing and other experiments
Everything so far asks whether the change is safe and correct. It does not tell you whether it is better. That is the job of experiments, and only for changes where the answer is genuinely uncertain and the metric is measurable.
What an A/B test is
An A/B test shows two or more versions to randomly chosen groups of real users at the same time, then compares a pre-chosen metric. Random assignment is the whole point: it removes payday, marketing campaigns, weather and every other confounder, so a gap between groups can be credited to the change.
Why teams do it
- Settle disagreements with data instead of seniority or taste.
- Limit blast radius. A bad idea shown to 5% of users is a cheap lesson.
- Improve metrics in small compounding steps: onboarding completion, conversion, retention.
- Decouple release from rollout. Ship code dark, turn it on remotely. On Android, where a store release is slow and irreversible, this matters more than on the web.
Ways to run experiments
| Way | What it is | Use it when |
|---|---|---|
| Classic A/B | One control, one variant | One clear change, one question |
| A/B/n, multivariate | Several variants or combinations of elements | Several candidates and plenty of traffic. Each extra arm needs more users |
| A/A test | Both groups see the same thing | You are validating a new experimentation setup. The result should be “no difference” |
| Client-side | App fetches config (Remote Config) and changes UI or flow | UI, copy, flows. All variants must already be in the build |
| Server-side | Backend picks the variant (ranking, pricing, recommendations) | Logic that should work for every app version |
| Store listing experiment | Play shows different icons, screenshots, text to visitors | Improving install conversion from store traffic |
| Holdout | A small group never receives new experiments | Measuring the long-term effect of a whole program of changes |
| Multi-armed bandit | Traffic shifts toward the winner during the test | Short windows where showing the worse variant is costly |
| Fake door | A button for a feature that does not exist, count taps | Measuring interest before building. Be honest with a “coming soon” screen |
| Canary as experiment | Gradual exposure compared with the rest, watching guardrails | Proving a technical change (new network stack) is harmless |
When not to A/B test
- Too little traffic. A small app cannot detect a 1% change in reasonable time. Use usability sessions, interviews or before-and-after.
- Legal, security or correctness changes. You do not test whether to fix a bug.
- Network effects. A chat or marketplace feature affects users outside their group. Randomize by region or time instead.
- The decision is already made. If you will ship B whatever the result, you are performing an A/B test, not running one.
Doing it properly
- Hypothesis: “If we cut sign-up to two screens, completion rises, because there is less effort.”
- One primary metric plus guardrails (crash-free users, startup time, uninstall rate).
- Sample size and duration set up front; at least one full weekly cycle.
- Sticky assignment per user or installation, and log exposure (the user saw the variant), not only assignment.
- Don’t peek. Early gaps are mostly noise.
- Decide, record, clean up. Remove the losing branch and the flag.
8. The architecture that makes this cheap
I judge a codebase by how safely it can ship. These design choices are what turn the process above from heroics into routine.
- Promote one artifact. The AAB that passed internal testing is the one that reaches production. Rebuilding between stages invalidates every earlier gate.
- Flag with discipline. Every flag has an owner and an expiry date. Old flags are code you cannot reason about. Test with the flag on and off.
- Never break a live contract. Some users stay on old versions for months. Support a minimum version, then force-update below it, deliberately.
- Treat analytics as an API. An experiment is only as good as the events behind it. Version them, review them, and test them like code.
- Separate config from code. Anything you might want to change after release (thresholds, copy, endpoints) belongs in remote config with safe defaults.
9. Tooling on Android
| Need | Tool | Notes |
|---|---|---|
| Automated tests | JUnit, Espresso, Compose testing, MockK, Robolectric, Turbine | Layer 1. See Testing Android apps |
| Device coverage | Firebase Test Lab, Gradle Managed Devices, emulators | Layer 2. Real hardware for the long tail |
| Performance | Macrobenchmark, Baseline Profiles, Android Studio Profiler | Layer 2. Put budgets in CI |
| Build distribution | Firebase App Distribution | Dogfooding and UAT builds outside Play. See overview |
| Test tracks | Play Console internal, closed, open testing | Layer 3. Also provides the pre-launch report |
| Staged rollout | Play Console staged rollout | Layer 4. Halt, resume, increase percentage |
| Stability signal | Crashlytics, Android vitals | Layer 4. See Crashlytics |
| Flags and config | Firebase Remote Config, LaunchDarkly, Statsig | Layers 4 and 5. See Remote Config |
| Experiments | Firebase A/B Testing, Optimizely, GrowthBook, PostHog, Amplitude Experiment, Eppo | Layer 5. Firebase is the lightest start; others add warehouse-native analysis and server-side SDKs |
| Store page | Store listing experiments | Layer 5. Icon, screenshots, descriptions |
| Acceptance tracking | Jira, TestRail, Zephyr | Layer 3. Scenarios, results, sign-off |
A small Remote Config read for an experiment, with the safe default that every experiment needs:
class Flags(private val rc: FirebaseRemoteConfig = Firebase.remoteConfig) {
init {
rc.setDefaultsAsync(mapOf(
"checkout_style" to "control", // safe fallback if fetch fails
"new_search_enabled" to false // also the kill switch
))
}
fun refresh() = rc.fetchAndActivate()
val checkoutStyle get() = rc.getString("checkout_style")
val newSearch get() = rc.getBoolean("new_search_enabled")
}
// Exposure is logged where the user actually sees the variant
analytics.logEvent("checkout_viewed") { param("style", flags.checkoutStyle) }
10. Running it as a team: ownership, gates, metrics
Tools are the easy part. What decides whether this works is whether people know who decides what.
| Gate | Owner | Entry | Exit | Time box |
|---|---|---|---|---|
| CI checks | Engineering | Pull request opened | Tests, lint and budgets pass | Minutes |
| Dogfood | Engineering lead | Merged to main | No open blocker from users | Continuous |
| Internal | QA / release manager | Release candidate cut | Smoke checklist passed | Hours |
| Closed beta | Product + QA | Internal passed | Crash-free and feedback targets met | Days |
| UAT | Product owner / business | Scenarios agreed, staging ready | Written sign-off | Fixed deadline |
| Staged rollout | Release manager + on-call | UAT accepted | 100% with gates green | Days to a week |
| Experiment | Product + data + engineering | Hypothesis and metrics written | Decision recorded, flag removed | Pre-agreed duration |
Scale the process to the risk
- Low risk (copy, small UI): CI, internal smoke, rollout at 20% steps.
- Medium (new screen, new API use): add closed beta and per-feature flag, finer rollout.
- High (payments, auth, data migration, regulated features): add UAT with sign-off, upgrade-path and security review, 1% canary with long observation, and a rehearsed kill switch.
Measure the process itself
I track a few numbers to see whether the system is improving: escaped defects per release (found in production), crash-free users per version, time from merge to production, change failure rate (releases needing a hotfix or halt), mean time to recover, and flaky test rate. If lead time grows while escaped defects stay flat, the gates are too heavy. If defects escape while gates are green, the gates test the wrong things.
11. Anti-patterns I keep seeing
- QA as a phase at the end. Quality is built in; a final phase only discovers the cost of not doing so.
- Users as the test suite. Skipping earlier layers and using an A/B test or full rollout to find bugs.
- Gates with no criteria. “Looks fine to me” is not an exit condition.
- UAT that tests code. UAT should check business intent; if it finds crashes, layers 1 to 3 failed.
- Rebuilding per environment. What was tested is not what shipped.
- Experiments without guardrails, or with the winner chosen after looking at 20 metrics.
- Flag graveyards. Hundreds of stale flags and untested combinations.
- Dogfooding as theater. A build nobody installs, or feedback nobody reads.
- Treating the rollback as free. On Android it is not. Design the kill switch up front.
- Name the risk first, then choose the test.
- Automate the cheap layers, use people for what machines cannot judge.
- Pre-agree thresholds so decisions during an incident are mechanical.
- Invest in flags, observability and compatibility before adding another test phase.
- Review the process after incidents, and remove gates that never catch anything.
12. Takeaways and glossary
- Release validation is a set of tools for different risks. A/B testing answers “which is better?”, not “is it safe?”
- Use five layers: automated tests, non-functional checks, human testing (dogfooding, internal, closed, UAT), staged rollout with observability, and experiments.
- Give every gate an owner, entry and exit criteria, and a time box. Scale the process to the risk of the change.
- Architecture is the lever: flags, a kill switch, compatible APIs, telemetry and one promoted artifact.
- Measure the release process itself and keep improving it.
| Term | In plain words |
|---|---|
| Dogfooding | Your own people use the unfinished product for real work. |
| Release candidate | The build you intend to ship, if no gate rejects it. |
| UAT | User acceptance testing: the business confirms the product does what was asked. |
| Track | A Google Play release channel: internal, closed, open or production. |
| Staged rollout | Releasing to a growing percentage of users while watching health. |
| Kill switch | A remote flag that disables a feature immediately, without a new release. |
| Canary | A very small first exposure used to detect harm early. |
| Primary / guardrail metric | The number that decides a test, and the numbers that must not get worse. |
| Exposure | The moment a user actually sees a variant, which is what you should count. |
| Escaped defect | A bug found in production that earlier gates should have caught. |