Skip to main content

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:
  1. Copies the frozen v1.0.0 database to a temporary location
  2. Creates a ModelContainer with your SchemaMigrationPlan
  3. Attempts migration from v1.0.0 → v2.0.0
  4. 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:
  1. Add FreezeRay/ folder to test target resources
  2. 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