Skip to main content

Testing Migrations

Learn how to write comprehensive migration tests with custom validation to ensure your SwiftData migrations preserve data integrity.

Overview

FreezeRay helps you write comprehensive migration tests with custom validation to ensure your SwiftData migrations preserve data integrity. What you’ll learn:
  • How to generate migration tests early in your workflow (new!)
  • How scaffolded migration tests work
  • Adding custom data validation
  • Testing edge cases (empty databases, large datasets)
  • Common migration pitfalls

Two Workflows: Old vs. New

❌ Old Workflow (generate after)

# 1. Freeze V1
freezeray freeze 1.0.0

# 2. Implement V2 schema + migration
# 3. Write custom migration test manually
# 4. Debug migration until test passes

# 5. Freeze V2
freezeray freeze 2.0.0
# → Generates migration test (too late!)
# → Must manually merge or delete generated file
Problem: Migration test generated after you’ve already written one.

✅ New Workflow (generate early)

# 1. Freeze V1
freezeray freeze 1.0.0

# 2. Generate migration test template immediately
freezeray generate migration-tests --from-schema 1.0.0 --to-schema 2.0.0

# 3. Edit generated test, add custom assertions
# 4. Implement V2 schema + migration
# 5. Iterate: run test → fix migration → run test

# 6. Freeze V2
freezeray freeze 2.0.0
# → Validates existing migration test passes
# → No conflict!
Benefit: Migration test created when you need it for development.
Use freezeray generate migration-tests to create the test when you start working on the migration, not after!

Prerequisites

  • You’ve frozen at least one schema version (e.g., 1.0.0)
  • You have a SchemaMigrationPlan defined (or plan to create one)
  • You’re ready to start working on the next schema version

The Scaffolded Migration Test

You can generate a migration test in two ways:
  1. Early (recommended): freezeray generate migration-tests -f 1.0.0 -t 2.0.0
  2. Automatically: freezeray freeze 2.0.0 (if 1.0.0 fixtures exist)
Either way, you get:
// AUTO-GENERATED by FreezeRay CLI
// This file is scaffolded once and owned by you. Customize as needed.

import Testing
import FreezeRay
@testable import MyApp

/// Migration test from v1.0.0 → v2.0.0
///
/// This test verifies that the migration path between these versions works correctly.
///
/// Note: Tests run serially to avoid SwiftData store conflicts
@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
    }
}
Generated tests now use @Suite(.serialized) to prevent parallel execution issues with SwiftData stores. This means tests work in the default test harness without special configuration!

What This Test Does

  1. Loads the frozen v1.0.0 SQLite database from FreezeRay/Fixtures/1.0.0/App-1_0_0.sqlite
  2. Copies it to a temporary location (bundle resources are read-only)
  3. Creates a ModelContainer with v2.0.0 schema and your migration plan
  4. Runs migration from v1.0.0 → v2.0.0
  5. Throws if migration crashes
If migration succeeds, the test passes. But it doesn’t verify data integrity - that’s your job!
The scaffolded test only checks that migration doesn’t crash. It doesn’t verify data is preserved correctly.

Adding Custom Validation

Let’s add data validation to ensure migration preserves user data.

Example Migration Scenario

Schema V1 (v1.0.0):
@FreezeSchema(version: "1.0.0")
enum AppSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [User.self]
    }

    @Model
    final class User {
        var name: String
        var email: String

        init(name: String, email: String) {
            self.name = name
            self.email = email
        }
    }
}
Schema V2 (v2.0.0):
@FreezeSchema(version: "2.0.0")
enum AppSchemaV2: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [User.self]
    }

    @Model
    final class User {
        var name: String
        var email: String
        var createdAt: Date  // New field

        init(name: String, email: String, createdAt: Date = Date()) {
            self.name = name
            self.email = email
            self.createdAt = createdAt
        }
    }
}
Migration:
static let migrateV1toV2 = MigrationStage.lightweight(
    fromVersion: AppSchemaV1.self,
    toVersion: AppSchemaV2.self
)

Step 1: Seed Test Data in V1

Before freezing v1.0.0, add test data to your frozen database:
// In your app or test target
func seedTestData() throws {
    let config = ModelConfiguration(schema: Schema(versionedSchema: AppSchemaV1.self))
    let container = try ModelContainer(
        for: AppSchemaV1.self,
        configurations: [config]
    )

    let context = container.mainContext

    // Add test users
    let users = [
        DataV1.User(name: "Alice", email: "[email protected]"),
        DataV1.User(name: "Bob", email: "[email protected]"),
        DataV1.User(name: "Charlie", email: "[email protected]")
    ]

    for user in users {
        context.insert(user)
    }

    try context.save()
}
You can seed data either manually (via Xcode’s data model editor) or programmatically before freezing.

Step 2: Add Validation to Migration Test

Update the scaffolded test with custom checks:
@Test("Migrate v1.0.0 → v2.0.0")
func testMigrateV1_0_0toV2_0_0() throws {
    // Run migration
    try FreezeRayRuntime.testMigration(
        from: AppSchemaV1.self,
        to: AppSchemaV2.self,
        migrationPlan: AppMigrations.self
    )

    // ✅ Custom validation: Verify data integrity after migration

    // Open the migrated database
    let fixtureURL = URL(fileURLWithPath: "FreezeRay/Fixtures/1.0.0/App-1_0_0.sqlite")
    let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
        .appendingPathComponent(UUID().uuidString)
        .appendingPathExtension("sqlite")

    try FileManager.default.copyItem(at: fixtureURL, to: tempURL)
    defer {
        try? FileManager.default.removeItem(at: tempURL)
    }

    // Create container with v2 schema (post-migration)
    let config = ModelConfiguration(
        schema: Schema(versionedSchema: AppSchemaV2.self),
        url: tempURL
    )

    let container = try ModelContainer(
        for: AppSchemaV2.self,
        migrationPlan: AppMigrations.self,
        configurations: [config]
    )

    let context = container.mainContext

    // Fetch all users
    let users = try context.fetch(FetchDescriptor<DataV2.User>())

    // 1. Verify record count matches
    #expect(users.count == 3, "Expected 3 users after migration")

    // 2. Verify existing fields preserved
    let names = Set(users.map { $0.name })
    #expect(names == ["Alice", "Bob", "Charlie"], "User names should be preserved")

    let emails = Set(users.map { $0.email })
    #expect(emails == ["[email protected]", "[email protected]", "[email protected]"],
            "User emails should be preserved")

    // 3. Verify new field has default values
    for user in users {
        #expect(user.createdAt != nil, "New createdAt field should have a default value")
    }

    // 4. Verify data types are correct
    for user in users {
        #expect(!user.name.isEmpty, "User name should not be empty")
        #expect(user.email.contains("@"), "User email should be valid")
    }
}

What This Validation Checks

  1. Record count - No users were lost during migration
  2. Existing fields - name and email are preserved
  3. New fields - createdAt has default values (not nil)
  4. Data types - Fields have sensible values

Common Validation Patterns

Pattern 1: Verify Record Counts

Ensure no data is lost:
let usersBefore = 100  // Known count in frozen fixture
let usersAfter = try context.fetch(FetchDescriptor<DataV2.User>())
#expect(usersAfter.count == usersBefore, "User count should match after migration")

Pattern 2: Check Required Fields

Ensure non-optional fields have values:
for user in users {
    #expect(!user.name.isEmpty, "Name should not be empty")
    #expect(!user.email.isEmpty, "Email should not be empty")
}

Pattern 3: Validate New Fields

Check that new fields have default values or proper initialization:
for user in users {
    #expect(user.createdAt != nil, "createdAt should have default value")
    #expect(user.createdAt <= Date(), "createdAt should be in the past")
}

Pattern 4: Verify Relationships

Ensure relationships are intact after migration:
let posts = try context.fetch(FetchDescriptor<DataV2.Post>())

for post in posts {
    #expect(post.author != nil, "Post should have an author after migration")
    #expect(post.author?.posts.contains(post) == true,
            "Bidirectional relationship should be intact")
}

Pattern 5: Check Data Transformations

If migration transforms data, verify correctness:
// Example: fullName was split into firstName + lastName
for user in users {
    let fullName = "\(user.firstName) \(user.lastName)"
    // Check that the split was correct (if you know original data)
    #expect(!user.firstName.isEmpty, "firstName should not be empty")
    #expect(!user.lastName.isEmpty, "lastName should not be empty")
}

Testing Custom Migrations

For custom migrations with didMigrate logic, test your transformation code:

Custom Migration Example

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: AppSchemaV1.self,
    toVersion: AppSchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        // Set default createdAt for existing users
        let users = try context.fetch(FetchDescriptor<DataV2.User>())
        for user in users {
            user.createdAt = Date.distantPast  // Set to old date for migrated users
        }
        try context.save()
    }
)

Test Custom Logic

@Test("Custom migration sets createdAt to distantPast")
func testMigrateV1_0_0toV2_0_0() throws {
    try FreezeRayRuntime.testMigration(/* ... */)

    let users = try context.fetch(FetchDescriptor<DataV2.User>())

    // Verify custom migration logic
    for user in users {
        #expect(user.createdAt == Date.distantPast,
                "Migrated users should have distantPast createdAt")
    }
}

Testing Edge Cases

Empty Database

Test that migration works with no data:
@Test("Migration works with empty database")
func testMigrateV1toV2Empty() throws {
    // Create empty v1 database
    let emptyURL = URL(fileURLWithPath: NSTemporaryDirectory())
        .appendingPathComponent(UUID().uuidString)
        .appendingPathExtension("sqlite")

    let config = ModelConfiguration(
        schema: Schema(versionedSchema: AppSchemaV1.self),
        url: emptyURL
    )

    let container = try ModelContainer(for: AppSchemaV1.self, configurations: [config])
    try container.mainContext.save()  // Empty database created

    // Run migration
    let migratedContainer = try ModelContainer(
        for: AppSchemaV2.self,
        migrationPlan: AppMigrations.self,
        configurations: [ModelConfiguration(url: emptyURL)]
    )

    // Verify no errors
    let users = try migratedContainer.mainContext.fetch(FetchDescriptor<DataV2.User>())
    #expect(users.isEmpty, "Empty database should remain empty after migration")
}

Large Dataset

Test performance with many records:
@Test("Migration handles large dataset")
func testMigrateV1toV2Large() throws {
    // Create database with many records
    let largeURL = URL(fileURLWithPath: NSTemporaryDirectory())
        .appendingPathComponent(UUID().uuidString)
        .appendingPathExtension("sqlite")

    let config = ModelConfiguration(
        schema: Schema(versionedSchema: AppSchemaV1.self),
        url: largeURL
    )

    let container = try ModelContainer(for: AppSchemaV1.self, configurations: [config])
    let context = container.mainContext

    // Insert 10,000 users
    for i in 0..<10_000 {
        let user = DataV1.User(name: "User \(i)", email: "user\(i)@example.com")
        context.insert(user)
    }
    try context.save()

    // Measure migration time
    let start = Date()
    let migratedContainer = try ModelContainer(
        for: AppSchemaV2.self,
        migrationPlan: AppMigrations.self,
        configurations: [ModelConfiguration(url: largeURL)]
    )
    let duration = Date().timeIntervalSince(start)

    // Verify all records migrated
    let users = try migratedContainer.mainContext.fetch(FetchDescriptor<DataV2.User>())
    #expect(users.count == 10_000, "All 10,000 users should be migrated")

    print("Migration of 10,000 records took \(duration) seconds")
}

Multi-Hop Migrations

Test migrations that span multiple versions: V1 → V2 → V3
@Test("Multi-hop migration: v1.0.0 → v3.0.0")
func testMigrateV1_0_0toV3_0_0() throws {
    // Load v1 fixture
    let fixtureURL = URL(fileURLWithPath: "FreezeRay/Fixtures/1.0.0/App-1_0_0.sqlite")
    let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
        .appendingPathComponent(UUID().uuidString)
        .appendingPathExtension("sqlite")

    try FileManager.default.copyItem(at: fixtureURL, to: tempURL)
    defer { try? FileManager.default.removeItem(at: tempURL) }

    // Migrate directly from v1 to v3 (SwiftData runs v1→v2→v3)
    let container = try ModelContainer(
        for: AppSchemaV3.self,
        migrationPlan: AppMigrations.self,
        configurations: [ModelConfiguration(url: tempURL)]
    )

    // Verify data survived multi-hop migration
    let users = try container.mainContext.fetch(FetchDescriptor<DataV3.User>())
    #expect(users.count > 0, "Users should survive multi-hop migration")

    // Verify all transformations applied correctly
    for user in users {
        #expect(!user.name.isEmpty, "Name should be preserved")
        #expect(user.createdAt != nil, "createdAt should be set (from v2)")
        // Add more checks for v3-specific changes
    }
}
Multi-hop migrations test cumulative effects of multiple migrations. Users might skip app versions, so always test the full path.

Common Migration Issues

Issue 1: Data Loss in Custom Migrations

Symptom: Records disappear after migration Cause:
// ❌ Bad: Deletes all data
didMigrate: { context in
    try context.delete(model: DataV1.User.self)
}
Fix:
// ✅ Good: Transform data without deleting
didMigrate: { context in
    let users = try context.fetch(FetchDescriptor<DataV2.User>())
    for user in users {
        user.createdAt = Date.distantPast
    }
    try context.save()
}
Test:
let countBefore = 10  // Known count
let users = try context.fetch(FetchDescriptor<DataV2.User>())
#expect(users.count == countBefore, "Data should not be deleted")

Issue 2: Nil Values for Non-Optional Fields

Symptom: Optional fields become nil unexpectedly Cause: SwiftData can’t provide defaults for new non-optional fields Fix:
// Add default value in custom migration
didMigrate: { context in
    let users = try context.fetch(FetchDescriptor<DataV2.User>())
    for user in users {
        if user.createdAt == nil {
            user.createdAt = Date.distantPast
        }
    }
    try context.save()
}
Test:
for user in users {
    #expect(user.createdAt != nil, "createdAt should not be nil")
}

Issue 3: Broken Relationships

Symptom: Relationships are nil after migration Cause: Inverse relationships not maintained Fix: Use SwiftData’s @Relationship properly with inverses Test:
for post in posts {
    #expect(post.author != nil, "Post should have author")
    #expect(post.author?.posts.contains(post) == true,
            "Inverse relationship should be intact")
}

Best Practices

Always add custom validation. The scaffolded test only checks for crashes, not data correctness.

1. Test with Real Data

Use your frozen fixtures with realistic data:
// Before freezing v1.0.0, seed with production-like data
let users = [
    User(name: "Alice Smith", email: "[email protected]"),
    User(name: "Bob Jones", email: "[email protected]"),
    // ... 100+ realistic users
]

2. Document Expected Behavior

Add comments explaining what each validation checks:
// Verify that the fullName split preserved data correctly
#expect(users.count == 50, "All 50 users should be preserved")

// Check that createdAt defaults to distantPast for migrated users
for user in users {
    #expect(user.createdAt == Date.distantPast)
}

3. Test Edge Cases

Don’t just test happy paths:
// Empty strings
// Nil optionals
// Large datasets
// Unicode characters
// Special characters in strings

4. Run Tests in CI

Migration tests should run on every PR:
# .github/workflows/test.yml
- name: Run Migration Tests
  run: xcodebuild test -scheme MyApp -only-testing:MyAppTests/MigrateV1_0_0toV2_0_0

Troubleshooting

”Fixture not found” error

Cause: Fixtures not included in test bundle Fix:
  1. In Xcode, select your test target
  2. Build Phases → Copy Bundle Resources
  3. Add FreezeRay/ folder (ensure it’s a folder reference, not group)

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: Trying to write to read-only bundle resource Fix: Always copy fixture to writable location:
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
    .appendingPathComponent(UUID().uuidString)
    .appendingPathExtension("sqlite")

try FileManager.default.copyItem(at: fixtureURL, to: tempURL)

Summary

You’ve learned how to:
  • ✅ Understand scaffolded migration tests
  • ✅ Add custom data validation
  • ✅ Test edge cases (empty DB, large datasets)
  • ✅ Verify multi-hop migrations
  • ✅ Catch common migration issues
Migration testing catches data corruption before it reaches production!

Next Steps