Migration Testing
Migration testing validates that your schema migrations work correctly by loading real frozen databases and attempting to migrate them. This catches crashes and data corruption before production.
The Problem
Fresh Installs vs. Migrations
When you test your app in the simulator, you’re almost always testing fresh installs:
// Simulator test (fresh install):
// 1. No existing database
// 2. SwiftData creates NEW database with current schema
// 3. No migration happens
// ✅ Test passes
// Production user (existing database):
// 1. Existing database with v1.0.0 schema
// 2. SwiftData must MIGRATE v1.0.0 → v2.0.0
// 3. Migration fails or corrupts data
// ❌ App crashes on launch
The result: Your tests pass, but real users crash.
Migration Crashes Are Silent Killers
Production crashes from broken migrations:
Cannot use staged migration with an unknown model version
This error means you modified a shipped schema. Fresh installs don’t catch this because they skip migration entirely.
Data Corruption Is Worse Than Crashes
Sometimes migrations “succeed” but silently corrupt data:
- Non-optional properties added without defaults → undefined behavior
- Transformable properties with changed types → old data becomes nil
- Properties removed → user data silently dropped
- Type conversions fail → data loss
Users don’t know their data is corrupted until it’s too late.
The Solution: Load Real Databases
FreezeRay tests migrations using actual frozen databases from shipped versions:
@Test("Migrate v1.0.0 → v2.0.0")
func testMigrateV1toV2() throws {
// Loads the REAL v1.0.0 database from FreezeRay/Fixtures/1.0.0/
// Attempts migration using your SchemaMigrationPlan
// Crashes in tests if migration is broken
try FreezeRayRuntime.testMigration(
from: AppSchemaV1.self,
to: AppSchemaV2.self,
migrationPlan: AppMigrations.self
)
}
If your migration is broken, you get the crash in your test suite where you can debug it.
How It Works
1. Frozen Fixtures as Test Data
When you freeze a schema, FreezeRay captures the SQLite database:
FreezeRay/Fixtures/
├── 1.0.0/
│ ├── App-1_0_0.sqlite ← Real v1.0.0 database
│ └── ...
└── 2.0.0/
├── App-2_0_0.sqlite ← Real v2.0.0 database
└── ...
These fixtures are committed to your repo and included in your test bundle.
2. Generate Migration Tests
When you start working on v2.0.0, generate the migration test:
freezeray generate migration-tests --from-schema 1.0.0 --to-schema 2.0.0
This creates a migration test template:
// AUTO-GENERATED by FreezeRay CLI
// This file is scaffolded once and owned by you. Customize as needed.
@Suite(.serialized)
struct MigrateV1_0_0toV2_0_0_Tests {
@Test("Migrate v1.0.0 → v2.0.0")
func testMigrateV1_0_0toV2_0_0() throws {
// Test the migration using FreezeRayRuntime
try FreezeRayRuntime.testMigration(
from: AppSchemaV1.self,
to: AppSchemaV2.self,
migrationPlan: AppMigrations.self
)
// TODO: Add data integrity checks here
// Example:
// - Verify data is preserved during migration
// - Check that new fields have default values
// - Validate relationship updates
// - Ensure no data loss for critical fields
}
}
3. Runtime Migration Testing
FreezeRayRuntime.testMigration() does the following:
- Copies the frozen v1.0.0 database to a temporary location
- Creates a ModelContainer with your
SchemaMigrationPlan
- Attempts migration from v1.0.0 → v2.0.0
- Throws if migration fails or crashes
If migration succeeds, control returns to your test where you can add custom validation.
Test Types
Generated Migration Tests
Created when you start working on a new schema version:
# When starting v2.0.0 development
freezeray generate migration-tests --from-schema 1.0.0 --to-schema 2.0.0
# Creates: FreezeRay/Tests/MigrateV1_0_0toV2_0_0_Tests.swift
What it tests:
- Migration doesn’t crash
- SwiftData can open the migrated database
- Basic migration mechanics work
What it doesn’t test (you add this):
- Data integrity validation
- Custom migration logic correctness
- Performance
Custom Data Validation
You should add validation to check data integrity:
@Test("Migrate v1.0.0 → v2.0.0")
func testMigrateV1_0_0toV2_0_0() throws {
try FreezeRayRuntime.testMigration(
from: AppSchemaV1.self,
to: AppSchemaV2.self,
migrationPlan: AppMigrations.self
)
// ✅ Add custom validation
let container = try ModelContainer(
for: AppSchemaV2.self,
configurations: ModelConfiguration(/* migrated db */)
)
let context = container.mainContext
let users = try context.fetch(FetchDescriptor<DataV2.User>())
// Verify record counts
#expect(users.count == 10, "Expected 10 users after migration")
// Check required fields preserved
for user in users {
#expect(!user.name.isEmpty, "User name should not be empty")
#expect(!user.email.isEmpty, "User email should not be empty")
}
// Validate new fields have defaults
for user in users {
#expect(user.createdAt != nil, "New createdAt field should have default")
}
}
Multi-Hop Migrations
SwiftData runs migrations sequentially: V1 → V2 → V3
FreezeRay tests each hop:
// Test direct migrations
@Test("Migrate v1.0.0 → v2.0.0")
func testMigrateV1toV2() throws { /* ... */ }
@Test("Migrate v2.0.0 → v3.0.0")
func testMigrateV2toV3() throws { /* ... */ }
// Optional: Test multi-hop migration
@Test("Migrate v1.0.0 → v3.0.0 (multi-hop)")
func testMigrateV1toV3() throws {
// Tests the full migration path: V1 → V2 → V3
try FreezeRayRuntime.testMigration(
from: AppSchemaV1.self,
to: AppSchemaV3.self,
migrationPlan: AppMigrations.self
)
}
Why test multi-hop?
- Users might skip app versions (1.0 user updates directly to 3.0)
- Cumulative migrations can expose bugs not visible in single hops
- Data transformations across multiple hops can cause unexpected issues
Common Migration Issues
Issue 1: Modified Shipped Schema
// ❌ You modified AppSchemaV1 after shipping
enum AppSchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[User.self, Post.self] // Added Post after shipping!
}
}
Error:
Cannot use staged migration with an unknown model version
Fix:
Don’t modify shipped schemas. Create AppSchemaV2 instead.
Issue 2: Missing Models
// ❌ Forgot to include User in V2
enum AppSchemaV2: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Post.self] // Where's User?
}
}
Error:
Persistent store migration failed, missing source managed object model
Fix:
Include ALL models in each schema version.
Issue 3: Data Loss in Custom Migrations
// ❌ Custom migration doesn't preserve data
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: AppSchemaV1.self,
toVersion: AppSchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// Deletes all users!
try context.delete(model: DataV1.User.self)
}
)
No error - migration succeeds, but data is gone!
Fix:
Add test validation to catch data loss:
@Test func testMigrateV1toV2() throws {
try FreezeRayRuntime.testMigration(/* ... */)
// ✅ Verify data preserved
let users = try context.fetch(FetchDescriptor<DataV2.User>())
#expect(users.count > 0, "Users should be preserved")
}
Best Practices
Always customize scaffolded tests. The TODO markers are reminders to add data validation.
Add Meaningful Assertions
// ❌ Bad: No validation
@Test func testMigration() throws {
try FreezeRayRuntime.testMigration(/* ... */)
}
// ✅ Good: Validates data integrity
@Test func testMigration() throws {
try FreezeRayRuntime.testMigration(/* ... */)
// Check record counts
#expect(users.count == expectedCount)
// Validate required fields
for user in users {
#expect(!user.email.isEmpty)
}
// Check relationships intact
for post in posts {
#expect(post.author != nil)
}
}
Test Edge Cases
@Test func testMigrationWithEmptyDatabase() throws {
// What if database has no records?
}
@Test func testMigrationWithMaxRecords() throws {
// What if database has thousands of records?
}
@Test func testMigrationWithCorruptedData() throws {
// What if transformable data is malformed?
}
Run Tests in CI
Migration tests should run on every PR:
# .github/workflows/test.yml
- name: Run Migration Tests
run: xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'
Troubleshooting
”Fixture not found” error
Cause: Fixtures not included in test bundle
Fix:
- Add
FreezeRay/ folder to test target resources
- Ensure folder reference is “folder” (blue) not “group” (yellow)
Migration test passes locally, fails in CI
Cause: Fixtures not committed to git
Fix:
git add FreezeRay/Fixtures/
git commit -m "Add migration fixtures"
Test crashes with “cannot open database”
Cause: Database file permissions in test bundle
Fix:
Copy fixture to writable location before testing (FreezeRayRuntime does this automatically)
Next Steps