Testing an iOS App With an In-App Test Runner, Launch Flags and XCUITest
274 logic checks run inside the app from a launch flag, a final check proves no test opened the real database, and six XCUITest flows drive the UI.
Tests that live inside the app
Most iOS projects put their tests in an XCTest bundle and run them from Xcode's test navigator. SpenDrop, my iOS expense tracker, started differently. Its first tests were plain Swift functions compiled into the app itself, run from a self-test screen in Settings. The project notes record the reason as testing without needing a Mac and Xcode in the loop: the checks travel with the app and run wherever it runs.
By version 1.4.0 the same approach carried 274 checks across twelve suites, could be run from the command line with a process exit code, and was joined by a proper XCUITest target for the UI. This is an account of how that works, what it costs, and the one bug it caught that I most needed caught. It is for iOS developers deciding how to test logic-heavy apps, especially ones with local data they cannot afford to touch.
Running suites from a launch argument
Every suite is a function that returns a list of results, each with a name, pass or fail, and the expected and actual values. A table maps each suite to a launch flag:
public static let suites: [(name: String, flag: String, run: () -> [TestCaseResult])] = [
("Existing", "--run-tests", { TransactionParserTests.runAllTests() }),
("Data safety", "--run-data-safety-tests", { DataSafetyTests.runAllTests() }),
("Phase 2", "--run-financial-tests", { FinancialModelTests.runAllTests() }),
// ...Phases 3 to 8...
("Authentication", "--run-auth-tests", { blocking { await AuthTests.runAllTests() } }),
("Cloud Backup", "--run-cloud-tests", { blocking { await CloudBackupTests.runAllTests() } }),
// Must stay last: proves none of the suites above touched the user's real database.
("Test isolation", "--run-all-tests", { isolationCheck() })
]The check happens in the app's init, before any scene or database exists. If a test flag is present, the selected suites run, every result is printed as a [TEST][suite] [PASS|FAIL] line, each suite prints one [SUITE_SUMMARY] <suite>: n/m PASSED line, and the process exits with 0 if everything passed and 1 otherwise. That makes it usable from a terminal or a script:
xcodebuild -project SpenDrop.xcodeproj -scheme SpenDrop \
-destination 'platform=iOS Simulator,name=iPhone 17' build
xcrun simctl launch --console-pty booted com.spendrop.SpenDrop --run-all-tests
# [SUITE_SUMMARY] Existing: 93/93 PASSED ... Test isolation: 1/1 PASSEDWhat the suites cover
The suites grew phase by phase with version 1.4.0, and each phase was gated by the full run before the next began:
| Suite | Covers |
|---|---|
| Existing | parsing, amounts, false positives, providers, reference screenshots as text, merchant rules, filters, reconciliation, PayBook, backup, share flow |
| Data safety | safe mode, corrupt store, frozen schemas, pre-upgrade copies, backup history |
| Phase 2 to Phase 8 (seven suites) | money conversion and splits, accounts, money in and out, shared expenses, balances, timeline, automation, migrations, hardening |
| Authentication | PKCE, Google exchange, email sign-in, refresh, offline, expiry |
| Cloud backup | upload, append-only, unchanged skipped, failure and retry, restore merge |
| Test isolation | no suite opened the real database |
Suites use in-memory SwiftData containers or temporary folders. The authentication and cloud suites run against an in-memory fake of Supabase's HTTP API, which is useful for testing the client's behaviour and says nothing about whether the real server agrees; Testing Without Paid APIs or Hardware discusses where fakes like that lie. The parser checks feed synthetic OCR text, so they test the rules, not how well Apple's Vision framework reads a screenshot.
The test that opened the real database
Version 1.4.0 taught the Share Extension to suggest a learned category for a merchant. I added that lookup to the function that applies a parsed transaction to the review form. The existing self-tests call that function, so the next test run opened the real store, and, because the schema had changed, upgraded it. Nothing was lost, and I reproduced it on a copy of V2 data, but a test run had just migrated a user's database.
The fix had two parts. The function went back to having no database access, and the suggestion moved to the real OCR path. Then I added a check that makes the mistake impossible to repeat silently. Test runs happen before the app opens its database, so the store status is still its initial value, safe mode with no store URL. If any suite had opened the real store, that status would have changed:
static func isolationCheck() -> [TestCaseResult] {
var untouched = false
if case .safeMode(_, let url) = ExpenseDataContainer.storeStatus, url == nil { untouched = true }
return [TestCaseResult(testName: "Test suites never open the real database", passed: untouched,
expected: "store never opened",
actual: untouched ? "never opened" : "OPENED",
details: "Isolation")]
}It is one check, but it is the one I trust most, because it tests the property I care about directly instead of trusting every suite to behave.
XCUITest for the flows people actually use
Logic tests cannot tell you that a button exists or that a balance appears on the right screen. Version 1.4.0 added a SpenDropUITests target with six flows: the five tabs in order; adding an expense; splitting a RM 30 expense with a new person and seeing "owes you RM 15.00" in PayBook; creating two accounts, recording Money In and a transfer, and checking filters and account totals; the cash-flow card and the Breakdown cash-flow view; and the Account screen in its not-configured state. Key controls carry accessibility identifiers so the tests do not depend on layout, and each flow attaches screenshots to the result bundle.
The UI tests launch the app with --ui-testing. In that mode the app creates a fresh temporary database on every launch, seeds no sample data and writes no backups, so the user's store and backup files are never touched. All six passed on 29 September 2026.
The trade-offs
An in-app runner has real costs. There is no test navigator integration and no code coverage, and the test code ships in the app binary. In exchange the checks run anywhere the app runs, need nothing but the app, and report through a process exit code, which is all a CI job needs.
SpenDrop does not have CI yet; the counts above are from a recorded run, not from a pipeline. Version 1.4.0 has not been run on a physical iPhone, the Share Extension and the Wallet automation have not been tested at runtime for this release, there is no VoiceOver audit, and there are no performance benchmarks. SpenDrop is not on the App Store. What the data-safety suite protects is described in SwiftData Migrations That Can't Lose Data.
Keep reading
Generating an Xcode Project File From a Script, With Stable IDs
Both of my iOS apps write project.pbxproj from a script. How the object IDs are made, why hashing names beats a counter, and what it costs.
SwiftData Migrations That Can't Lose Data: VersionedSchema, Pre-Upgrade Copies and a Safe Mode
SwiftData's inferred migration silently dropped a table. How SpenDrop moved to VersionedSchemas, pre-upgrade copies and a safe mode that never deletes.
Testing Without Paid APIs or Hardware, and the Places Fakes Lied
Fake LLM providers, a simulated label printer and an in-process Postgres keep my test suites free and offline. What each proved, and three places a fake fell short.