> ## Documentation Index
> Fetch the complete documentation index at: https://docs.freezeray.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing Migrations

> Guide to testing schema migrations

# 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)

```bash theme={null}
# 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)

```bash theme={null}
# 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.

<Info>
  Use `freezeray generate migration-tests` to create the test **when you start** working on the migration, not after!
</Info>

## 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:

```swift theme={null}
// 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
    }
}
```

<Info>
  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!
</Info>

### 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!

<Warning>
  The scaffolded test only checks that migration **doesn't crash**. It doesn't verify data is preserved correctly.
</Warning>

## Adding Custom Validation

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

### Example Migration Scenario

**Schema V1 (v1.0.0):**

```swift theme={null}
@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):**

```swift theme={null}
@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:**

```swift theme={null}
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:

```swift theme={null}
// 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: "alice@example.com"),
        DataV1.User(name: "Bob", email: "bob@example.com"),
        DataV1.User(name: "Charlie", email: "charlie@example.com")
    ]

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

    try context.save()
}
```

<Info>
  You can seed data either manually (via Xcode's data model editor) or programmatically before freezing.
</Info>

### Step 2: Add Validation to Migration Test

Update the scaffolded test with custom checks:

```swift theme={null}
@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 == ["alice@example.com", "bob@example.com", "charlie@example.com"],
            "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:

```swift theme={null}
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:

```swift theme={null}
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:

```swift theme={null}
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:

```swift theme={null}
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:

```swift theme={null}
// 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

```swift theme={null}
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

```swift theme={null}
@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:

```swift theme={null}
@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:

```swift theme={null}
@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

```swift theme={null}
@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
    }
}
```

<Info>
  Multi-hop migrations test cumulative effects of multiple migrations. Users might skip app versions, so always test the full path.
</Info>

## Common Migration Issues

### Issue 1: Data Loss in Custom Migrations

**Symptom:** Records disappear after migration

**Cause:**

```swift theme={null}
// ❌ Bad: Deletes all data
didMigrate: { context in
    try context.delete(model: DataV1.User.self)
}
```

**Fix:**

```swift theme={null}
// ✅ 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:**

```swift theme={null}
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:**

```swift theme={null}
// 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:**

```swift theme={null}
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:**

```swift theme={null}
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

<Warning>
  **Always add custom validation.** The scaffolded test only checks for crashes, not data correctness.
</Warning>

### 1. Test with Real Data

Use your frozen fixtures with realistic data:

```swift theme={null}
// Before freezing v1.0.0, seed with production-like data
let users = [
    User(name: "Alice Smith", email: "alice@example.com"),
    User(name: "Bob Jones", email: "bob@example.com"),
    // ... 100+ realistic users
]
```

### 2. Document Expected Behavior

Add comments explaining what each validation checks:

```swift theme={null}
// 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:

```swift theme={null}
// Empty strings
// Nil optionals
// Large datasets
// Unicode characters
// Special characters in strings
```

### 4. Run Tests in CI

Migration tests should run on every PR:

```yaml theme={null}
# .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:**

```bash theme={null}
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:

```swift theme={null}
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

<Columns cols={2}>
  <Card title="CI Integration" icon="github" href="/guides/ci-integration">
    Run migration tests in your CI pipeline
  </Card>

  <Card title="Migration Testing Concepts" icon="code-branch" href="/concepts/migration-testing">
    Deep dive into how migration testing works
  </Card>

  <Card title="First Freeze" icon="snowflake" href="/guides/first-freeze">
    Tutorial for freezing your first schema
  </Card>

  <Card title="Drift Detection" icon="triangle-exclamation" href="/concepts/drift-detection">
    Understand drift detection mechanics
  </Card>
</Columns>
