How to Test App Upgrades and Data Migration on iOS and Android
A fresh install never meets the data your users already have. Test the iOS and Android upgrade path, where migrations break, on every release candidate.
A fresh install starts from nothing. Your users never do. They update, and the new build opens a database, a keychain, and a set of preferences the old build wrote. An install test never walks that path, so it never fails on it.
Miss one migration and the update either crashes on launch or quietly wipes a user’s data. Here is how to test that path before your users do.
What you’ll learn
- Why upgrades break flows that clean installs pass
- The five storage layers where user data disappears
- How Room and Core Data migrations fail, and how to test them
- The adb and simctl steps to run an upgrade in place
- How to automate the whole thing on every release candidate
Why Upgrades Break When Fresh Installs Pass
An upgrade runs new code against old data, and a fresh install never does. On an update from the App Store or Play Store, the OS keeps the app’s stored data and swaps in the new binary. Picture replacing an engine while the car keeps its mileage and the luggage in the trunk. The new binary has to read everything the old one wrote. Each assumption about that data’s shape is a place to break.
Install tests miss this by construction. They provision a clean device. The app boots with no prior state, and every read returns what the current code wrote. The upgrade path sits underneath every scenario in a mobile app testing strategy, and install-only suites never touch it.
Adding a column is a one-line diff. The migration for the rows that already exist is a second change that nothing in the first diff asks for. A missing migration costs nothing to ship and stays invisible until a real user updates.
TLDR: An install test checks new code on new data. An upgrade test checks new code on old data.
Five Places an Upgrade Loses Data
User data on a phone sits in five storage layers, and each one migrates on its own rules. One can break while the other four pass. Assert all five.
| Storage layer | What it holds | How an upgrade breaks it | What to assert after upgrade |
|---|---|---|---|
| Local database (SQLite, Room, Core Data) | Structured user records | Schema change with no migration path crashes on launch or wipes tables | Row counts and values from the old version are intact |
| Key-value store (SharedPreferences, UserDefaults) | Settings, flags, small tokens | Renamed keys read back empty and defaults silently reappear. On Android, a retyped key throws on read | Saved settings and onboarding-complete flags persist |
| Secure store (Keystore, Keychain) | Auth tokens, session, credentials | Key alias or accessibility change locks the token out, forcing a logout | The user stays logged in across the update |
| Files and cache | Downloads, images, offline content | Stored absolute paths go stale and changed formats orphan the old version’s files | Cached and downloaded content loads or re-fetches cleanly |
| Server-driven state | Entitlements, subscription, feature flags | New client reads an old entitlement or receipt shape | Subscription and paywall state resolve correctly |
Four details change what you assert:
- Keychain: On iOS, Keychain items have survived an app delete in practice, so a login can outlast a delete-and-reinstall. Apple’s developer forums call that an undocumented implementation detail. A reinstall test can hide the logout bugs that an in-place upgrade exposes.
- File paths: An iOS app’s container gets a new absolute path when the app updates. A full path the old build saved in its database then points at nothing. Apple’s technote on testing iOS app updates says to store paths relative to the container instead.
- Server-driven state: Subscription entitlements go wrong on update when the new client reads an old receipt shape. The guide to testing in-app subscriptions covers the entitlement side.
- Permissions: A grant given at onboarding has to survive the update, and Android auto-resets permissions when an app targeting Android 11 or higher goes unused for a few months. Test app permissions across the upgrade as well as on first run.
How to Test Database Schema Migration
Test a schema migration twice: in isolation, to prove each step works, then end to end, to prove the old build’s real data survives. The local database is where an upgrade does the most visible damage, because a missing migration does not degrade gracefully. It crashes the app on launch or deletes the user’s records. In Room, one configuration flag decides which.
Test Room Migrations on Android
Room is Android’s Jetpack library for local databases, an abstraction layer over SQLite. It resolves a chain of Migration objects between schema version numbers, and each Migration moves the schema from one version to the next. The code below is Room 2.x. Room 3 makes migrate a suspend function over a SQLiteConnection, and the rules below hold in both:
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE pets ADD COLUMN collar_id TEXT")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_2_3, MIGRATION_3_4) // every step must be registered
.build()
Room’s documentation is explicit about a missing step. If it “can’t find a migration path to upgrade an existing database on a device to the current version, an IllegalStateException occurs.” The app crashes on launch.
The tempting fix is fallbackToDestructiveMigration(). Android’s docs warn what it does: Room “permanently deletes all data from the tables in the user’s database when it attempts to perform a migration and there’s no defined migration path.” So the two failure modes are a launch crash or a silent, total data wipe, and a fresh-install test shows neither.
Prove each step in isolation first with Room’s MigrationTestHelper, which the same docs cover under “Test migrations.” The helper creates a database at an old schema version, and you insert rows with plain SQL because DAOs expect the latest schema. Then runMigrationsAndValidate applies your migrations and checks the result against the new schema. It checks the schema only. Asserting the data is on you.
Test Core Data Migrations on iOS
Core Data is Apple’s framework for persisting an app’s data on the device. Its lightweight migration infers the mapping for obvious changes such as adding, removing, or renaming an attribute. A change outside that list, such as merging entity hierarchies, needs a custom mapping model you ship yourself. An attribute type change is not on Apple’s list either. Without a mapping, loading the store fails with a Core Data error, and the app launches into an error state instead of the user’s data.
To test it, keep a store file written by the old model version in your test bundle, copy it to a temporary path, and open it with the new model. The test passes only if the store loads and the old rows read back.
Make one of those store files large. Apple lists a synchronous lightweight migration on a large Core Data store among the causes of watchdog terminations, where iOS ends an app whose main thread stays blocked too long. A store with a handful of test rows never gets near that limit.
| Platform | Automatic path | When it fails | Failure mode on a user’s device |
|---|---|---|---|
| Android (Room) | Registered Migration objects that cover every version step | A version jump has no migration path defined | IllegalStateException crash, or full data wipe if destructive fallback is on |
| iOS (Core Data) | Lightweight migration infers simple model changes | Changes outside the inferable set, such as merging entity hierarchies | Persistent store fails to load unless a mapping model ships |
The two platforms diverge enough that one test plan will not cover both. Build the migration matrix per platform.
When a Migration Needs a Table Rebuild
Room sits on SQLite, as does Core Data when it uses its SQLite store. SQLite supports a limited subset of ALTER TABLE, and a column type change is outside it. The migration has to create a new table, copy the rows across, drop the old table, and rename the new one. That copy step is where a hand-written migration can drop rows. Seed every table before you migrate.
On older Android, dropping a column needs the same rebuild. DROP COLUMN arrived in SQLite 3.35. Android’s platform SQLite is 3.32 or older through API 33. A Room migration on the framework driver that drops a column can pass on an API 35 emulator and fail on an Android 13 phone. BundledSQLiteDriver avoids that by compiling in its own SQLite.
TLDR: Prove each migration step in isolation. Then upgrade the last production build and check the data survived.
How to Run an Upgrade Test in Four Steps
Run it in four steps: install the old build, generate real state, upgrade in place, then assert everything survived. Everything rides on step three.
- Install the last production build. Not the release candidate. The test has to start from the build your users run today.
- Generate real state. Log in, create records, change settings, download something for offline use, start a subscription if you sell one. The closer the state is to a user’s, the more the test is worth. An empty database migrates fine, and a full one is where bugs live.
- Upgrade over the top. Install the release candidate without uninstalling. The OS keeps the existing files and runs the new binary against them, as a store update does.
- Assert survival, layer by layer. Walk the five layers from the table above. Is the session valid, are the records all present, did settings hold, does cached content load, is the subscription active? Any value that comes back empty or default is a defect.
Delete-and-install wipes the app sandbox, so the new build never reads the old files, database, or preferences and the bugs you are hunting cannot appear. If your script uninstalls first, a green run protects no real user.
Upgrade testing is regression work. Every schema change or storage refactor reopens the risk. The upgrade path belongs in your mobile regression testing suite, run against every release candidate.
Stop Hand-Writing Regression Checks
Upload your build. Pie explores the app and generates the tests.
Book a DemoWhich Version Jumps to Test
Test the version jumps your users make, not every possible pair. A user does not always update from the previous version. Apple’s update-testing technote notes that the App Store installs the latest version without running the intermediate updates. Someone who skipped several releases gets every schema change between their version and yours applied in one launch, and a missing step hides in that longer chain. Testing only consecutive versions never walks it.
Build the matrix from your own install analytics. Cover at least these three jumps.
- Previous release to current: The most common path and your baseline. If this breaks, the largest group of updating users hits it.
- Oldest supported version to current: The longest migration chain you have to support, set by the oldest build that still holds a meaningful share of installs.
- Across a major boundary: Releases that changed schema, storage format, or auth accumulate migration debt, so an upgrade spanning one is higher risk than its version distance suggests.
You cannot test every historical version against every newer one, and you should not try. The combinatorics explode and most pairs represent nobody. Weight the matrix by where your users sit, as you would a device matrix.
How to Automate Upgrade Testing in CI
Automate upgrade testing by storing the previous production artifact next to the release candidate, then scripting an install-seed-upgrade-assert sequence in your pipeline. A manual upgrade test runs occasionally, while the schema changes that cause upgrade bugs land whenever a feature needs a new field. The pipeline has to keep the old build and drive the upgrade itself.
On Android the core of it is two installs and a relaunch. The -r flag reinstalls an existing app and keeps its data, which makes the second install an in-place upgrade.
# 1. Install the current production build, then seed real state
adb install -r app-prod.apk
# run a script that logs in and creates records...
# 2. Upgrade in place with the release candidate (-r keeps existing data)
adb install -r app-rc.apk
# 3. Relaunch and let the regression suite assert the data survived
adb shell am start -n com.yourcompany.app/.MainActivity
On iOS the shape is the same. The build has to be a simulator build.
# 1. Install the prior simulator build, then seed real state
xcrun simctl install booted App-prod.app
# run a script that logs in and creates records...
# 2. Install the candidate over it, without erasing the device
xcrun simctl install booted App-rc.app
# 3. Relaunch and run your assertions
xcrun simctl launch booted com.yourcompany.app
Run the sequence by hand once and confirm the seeded data survives the second install before you wire it into CI. Google also documents a way to exercise the store’s own in-app update flow through internal app sharing, which is worth adding once the data-layer test is solid. Two cautions apply either way:
- Seed through the app: Create the state through the app’s own flows instead of pushing a fixture database onto the device. A hand-built fixture can encode a schema your old version never produced, and then you are testing a migration from a state that never existed.
- Expect flake: Upgrade tests inherit every source of mobile E2E flakiness plus a stateful setup phase. Keep the seeding hermetic and synchronize assertions on app state, the same discipline that stabilizes the rest of your mobile test automation.
TLDR: Keep last production’s artifact. Install it, seed state, install the candidate over it, then assert.
Where Pie Fits in an Upgrade Test
The install-over-install steps are a short script. What decays is the suite that checks the result: login, saved records, settings, each one rewritten whenever the UI moves. That upkeep is an easy reason to drop upgrade testing from the release checklist.
Pie, an autonomous QA platform for native iOS and Android apps, takes the suite. Upload the build your pipeline produces, an .apk for Android or a simulator .app for iOS, through the portal or a GitHub Action. Pie’s agents explore the app and generate tests from the flows they find. The tests run on an Android Emulator or an iOS Simulator, so the iOS candidate has to be a simulator build, and Pie does not accept a device .ipa.
After that first pass, the upkeep looks like this:
- Later builds: With auto-run on build turned on, each build you upload runs against that suite, and you can start a run on demand.
- UI changes: When a redesign moves a recognizable button, Pie can still find it by context, position, and function, so the move may need no locator repair. If the intended behavior changes, you edit the step.
- Regressions: Pie Loop can prepare a proposed fix as a pull request for your team to review, when repo access is connected and fix settings allow it.
Upgrade in Place, Every Release
An install test proves the app works for someone who has never opened it. Everyone else meets new code on top of old data the day they update.
What works is small. Upgrade in place, assert all five storage layers, weight the version jumps by your analytics, and run it on every release candidate.
Pie generates the regression flows you assert against, and its tests can find a moved or renamed button without a selector fix. Ship the upgrade test with your next migration.
Keep Upgrades on the Checklist
Let Pie cover the regression flows. Keep the upgrade test.
Book a DemoFrequently Asked Questions
Installation testing checks that a clean install launches and runs with no prior state. Upgrade testing checks that a new build inherits the data an existing user already has, which a fresh install never exercises.
Install the current production build, create real state, then install the release candidate over it without uninstalling. On Android use adb install -r. Relaunch and confirm the session, the records, and the settings all survived.
The new build is probably reading old data in a shape it no longer expects. In Android's Room library, a missing migration step throws an IllegalStateException on launch. In Apple's Core Data, a model change with no mapping stops the store from opening.
No. Deleting an app wipes its sandbox, so the new build starts clean and never reads the old files, database, or preferences. That makes it an install test. On iOS the Keychain can survive the delete, so even a logout bug can hide.
Use Room's MigrationTestHelper to create a database at the old version, insert rows with plain SQL, then run your migrations and validate the schema. Assert the migrated data yourself, then upgrade a real old build to confirm it end to end.
Test the jumps your analytics show real users making. Start with the previous release to the current one, add the oldest version still in meaningful use, and add any jump that crosses a schema, storage, or auth change.
Keep the last production artifact next to the release candidate. Install the old build, seed state through the app, install the candidate over it, then run your assertions. On Android that is two adb install -r calls.
Pie takes an .apk for Android and a simulator .app for iOS, zipped or not, uploaded through the portal or a GitHub Action. An iOS device build such as an .ipa does not work, so build the candidate for the simulator.