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:
- Early (recommended):
freezeray generate migration-tests -f 1.0.0 -t 2.0.0
- 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
- Loads the frozen v1.0.0 SQLite database from
FreezeRay/Fixtures/1.0.0/App-1_0_0.sqlite
- Copies it to a temporary location (bundle resources are read-only)
- Creates a ModelContainer with v2.0.0 schema and your migration plan
- Runs migration from v1.0.0 → v2.0.0
- 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
- Record count - No users were lost during migration
- Existing fields -
name and email are preserved
- New fields -
createdAt has default values (not nil)
- 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")
}
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:
- In Xcode, select your test target
- Build Phases → Copy Bundle Resources
- 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