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)
// AUTO-GENERATED by FreezeRay CLI// This file is scaffolded once and owned by you. Customize as needed.import Testingimport 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!
@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):
Copy
@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:
Copy
static let migrateV1toV2 = MigrationStage.lightweight( fromVersion: AppSchemaV1.self, toVersion: AppSchemaV2.self)
Before freezing v1.0.0, add test data to your frozen database:
Copy
// In your app or test targetfunc 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()}
You can seed data either manually (via Xcode’s data model editor) or programmatically before freezing.
@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") }}
let usersBefore = 100 // Known count in frozen fixturelet usersAfter = try context.fetch(FetchDescriptor<DataV2.User>())#expect(usersAfter.count == usersBefore, "User count should match after migration")
Check that new fields have default values or proper initialization:
Copy
for user in users { #expect(user.createdAt != nil, "createdAt should have default value") #expect(user.createdAt <= Date(), "createdAt should be in the past")}
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")}
// Example: fullName was split into firstName + lastNamefor 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")}
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("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")}
// ❌ Bad: Deletes all datadidMigrate: { context in try context.delete(model: DataV1.User.self)}
Fix:
Copy
// ✅ Good: Transform data without deletingdidMigrate: { context in let users = try context.fetch(FetchDescriptor<DataV2.User>()) for user in users { user.createdAt = Date.distantPast } try context.save()}
Test:
Copy
let countBefore = 10 // Known countlet users = try context.fetch(FetchDescriptor<DataV2.User>())#expect(users.count == countBefore, "Data should not be deleted")
Symptom: Optional fields become nil unexpectedlyCause: SwiftData can’t provide defaults for new non-optional fieldsFix:
Copy
// Add default value in custom migrationdidMigrate: { 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:
Copy
for user in users { #expect(user.createdAt != nil, "createdAt should not be nil")}
Symptom: Relationships are nil after migrationCause: Inverse relationships not maintainedFix: Use SwiftData’s @Relationship properly with inversesTest:
Copy
for post in posts { #expect(post.author != nil, "Post should have author") #expect(post.author?.posts.contains(post) == true, "Inverse relationship should be intact")}
Add comments explaining what each validation checks:
Copy
// 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 usersfor user in users { #expect(user.createdAt == Date.distantPast)}