How to Test SwiftData Schema Updates and Data Migrations

In this article, I would like to cover technical specifics related to SwiftData schema updates and the unit testing of data migration.

Understanding iOS Application Updates

Let’s start with some specifics regarding iOS application updates:

  • No Rollbacks: There is no such thing as an application version rollback; you can only publish a new version of the application.
  • The Upgrade Chain: There is no native way to enforce a strict upgrade chain. Moving from version 1.0.0 to a theoretical 1.9.9 is a realistic user scenario that must be tested.
    These two points define the reality of SwiftData updating and migration, and our project structure should reflect this. All versions of the application should be upgradeable to the latest one.

Goals for Project Structure and Testing Solution

  1. Testing should cover updates from every previous SwiftData schema to the current one.
  2. Testing should validate the integrity of migrated data against the original data.
  3. We should be able to use previously saved model containers for testing.
  4. We should be able to use programmatically defined test data sets

Required Procedures

To achieve these goals, we should:

  • Use VersionedSchema for each schema update.
  • Provide a SchemaMigrationPlan for each scheme update.
  • Prepare test data for each schema update (or update the migration code for previously created test data sets).
  • Save model container files containing both real-life data and mock data for future tests.

Implementation

The complete source code for this project is available on GitHub:: https://github.com/AndreiMaksimovich/SwiftDeveloperBlog/tree/main/06%20-%20SwiftData%20Migration

Example Project

Schemas:

enum AppDataSchemaV01: VersionedSchema {
    static let models: [any PersistentModel.Type] = [Path.self, PathPoint.self]
    static let versionIdentifier = Schema.Version(0, 1, 0)

    @Model
    class Path {
        #Index([\Path.uid])
        #Unique([\Path.uid])
        
        var uid: UUID
        var title: String
        @Relationship(deleteRule: .cascade) var points: [PathPoint]

        init(uid: UUID = .init(), title: String, points: [PathPoint]) {
            self.uid = uid
            self.title = title
            self.points = points
        }
    }

    @Model
    class PathPoint {
        var lattitude: Double
        var longitude: Double
        var time: Date
        init(lattitude: Double, longitude: Double, time: Date = .now) {
            self.lattitude = lattitude
            self.longitude = longitude
            self.time = time
        }
    }
}

// V02 - V04

enum AppDataSchemaV05: VersionedSchema {
    static let models: [any PersistentModel.Type] = [Path.self, PathPoint.self]
    static let versionIdentifier = Schema.Version(0, 5, 0)
    
    // ...
   
}

Migration plans:

fileprivate nonisolated let migrateV1toV2 = MigrationStage.lightweight(fromVersion: AppDataSchemaV01.self, toVersion: AppDataSchemaV02.self)

fileprivate nonisolated let migrateV2toV3 = MigrationStage.custom(
    fromVersion: AppDataSchemaV02.self,
    toVersion: AppDataSchemaV03.self,
    willMigrate: nil,
    didMigrate: {context in
        // ...
    }
)

fileprivate nonisolated let migrateV3toV4 = MigrationStage.lightweight(fromVersion: AppDataSchemaV03.self, toVersion: AppDataSchemaV04.self)

fileprivate nonisolated let migrateV4toV5 = MigrationStage.lightweight(fromVersion: AppDataSchemaV04.self, toVersion: AppDataSchemaV05.self)

enum AppDataMigrationPlanV01: SchemaMigrationPlan {
    static let schemas: [any VersionedSchema.Type] = [
        AppDataSchemaV01.self
    ]
    static let stages: [MigrationStage] = []
}

enum AppDataMigrationPlanV02: SchemaMigrationPlan {
    static let schemas: [any VersionedSchema.Type] = [
        AppDataSchemaV01.self,
        AppDataSchemaV02.self
    ]
    static let stages: [MigrationStage] = [
        migrateV1toV2
    ]
}

// ... V03 - V04  

enum AppDataMigrationPlanV05: SchemaMigrationPlan {
    static let schemas: [any VersionedSchema.Type] = [
        AppDataSchemaV01.self,
        AppDataSchemaV02.self,
        AppDataSchemaV03.self,
        AppDataSchemaV04.self,
        AppDataSchemaV05.self,
    ]

    static let stages: [MigrationStage] = [
        migrateV1toV2,
        migrateV2toV3,
        migrateV3toV4,
        migrateV4toV5,
    ]
}

Types:

typealias AppDataMigrationPlan = AppDataMigrationPlanV05
typealias AppDataSchema = AppDataSchemaV05

typealias Path = AppDataSchemaV05.Path
typealias PathPoint = AppDataSchemaV05.PathPoint

Note: You cannot migrate back to the same schema even if it is redefined with a new schema version. SwiftData calculates the schema fingerprint based on models and their fields; it will throw an error if a schema with the same fingerprint appears again in the migration chain.

Testing

With the schemas and migration plans defined, we can create or open ModelContainer instances for every schema version.

Utilizing Model Container Files for Testing

To use files inside unit tests, we must include them in the test bundle. Below is an example of how these files can be accessed:

fileprivate class TestClass {}

@MainActor
func getModuleContainerUrlFromTestBundle(_ forResource: String) -> URL? {
    return Bundle(for: TestClass.self).url(forResource: forResource, withExtension: nil)
}

Mock Data

Since ModelContext operates on abstract PersistentModel instances, we can use an abstraction layer to insert mock data and validate it against the ModelContainer contents.

Note: Once a set of persistent models is inserted into a ModelContainer and that container is deinitialized, the data can no longer be used for integrity checks; it is effectively discarded.

public extension PersistentModel {
    static var typeId: ObjectIdentifier {
        ObjectIdentifier(self)
    }
}

typealias AppDataMockDataSet = [AnyHashable: [any PersistentModel]]

nonisolated var appDataMockDataSetV01: AppDataMockDataSet {
    [
        AppDataSchemaV01.Path.typeId : [
	        // ....
        ],
        // ...
	]
}

Model Container Initialization

Now we can initialize ModelContainer instances leveraging mock data, bundled files, and VersionedSchema and SchemaMigrationPlan enums.

@MainActor
func initializeAppDataMockModelContainer(
    url: URL,
    erase: Bool = true,
    versionedSchema: any VersionedSchema.Type,
    migrationPlan: (any SchemaMigrationPlan.Type)? = nil,
    data: AppDataMockDataSet?
) throws -> ModelContainer {
    if erase {
        try removeModelContainer(url)
    }
    
    let modelContainer = try ModelContainer(for: .init(versionedSchema: versionedSchema), migrationPlan: migrationPlan, configurations: [.init(url: url)])
    
    // Data
    if let data {
        let context = modelContainer.mainContext
        for (_, dataSet) in data {
            for instance in dataSet {
                context.insert(instance)
            }
        }
        try context.save()
    }

    return modelContainer
}

SwiftData Migration Testing

Let’s define an abstraction layer for data migration testing to help us achieve our goals:

typealias AppDataValidator = (_ test: AppDataMigrationTest, _ initialModel: ModelContainer, _ model: ModelContainer) throws -> Void

struct AppDataMigrationTest {
    let setupStep: SetupStep
    let upgradeSteps: [UpgradeStep]
    let test: Test

    init(setupStep: SetupStep, upgradeSteps: [UpgradeStep] = [], test: Test) {
        self.setupStep = setupStep
        self.upgradeSteps = upgradeSteps
        self.test = test
    }

    struct SetupStep {
        let versionedSchema: any VersionedSchema.Type
        let migrationPlan: (any SchemaMigrationPlan.Type)?
        let data: AppDataMockDataSet?
        let url: URL?
        let setupAction: ((ModelContainer) -> Void)?

        init(
            versionedSchema: any VersionedSchema.Type,
            migrationPlan: (any SchemaMigrationPlan.Type)? = nil,
            data: AppDataMockDataSet? = nil, url: URL? = nil,
            setupAction: ((ModelContainer) -> Void)? = nil,
        ) {
            self.versionedSchema = versionedSchema
            self.migrationPlan = migrationPlan
            self.data = data
            self.url = url
            self.setupAction = setupAction
        }
    }

    struct UpgradeStep {
        let versionedSchema: any VersionedSchema.Type
        let migrationPlan: (any SchemaMigrationPlan.Type)
        let setupAction: ((ModelContainer) -> Void)?

        init(
            versionedSchema: any VersionedSchema.Type,
            migrationPlan: any SchemaMigrationPlan.Type,
            setupAction: ((ModelContainer) -> Void)? = nil
        ) {
            self.versionedSchema = versionedSchema
            self.migrationPlan = migrationPlan
            self.setupAction = setupAction
        }
    }

    struct Test {
        let versionedSchema: any VersionedSchema.Type
        let migrationPlan: any SchemaMigrationPlan.Type
        let validator: AppDataValidator
        let expectedData: AppDataMockDataSet?

        init(
            versionedSchema: any VersionedSchema.Type,
            migrationPlan: (any SchemaMigrationPlan.Type),
            validator: @escaping AppDataValidator,
            expectedData: AppDataMockDataSet? = nil)
        {
            self.versionedSchema = versionedSchema
            self.migrationPlan = migrationPlan
            self.validator = validator
            self.expectedData = expectedData
        }
    }
}

@MainActor
func runAppDataMigrationTest(_ migrationTest: AppDataMigrationTest) throws {
    // Setup
    do {
        let setupStep = migrationTest.setupStep
        if setupStep.url != nil {
            try copyModelContainer(src: setupStep.url!, dst: defaultAppDataMockModelContainerUrl)
        }

        let modelContainer = try initializeAppDataMockModelContainer(url: defaultAppDataMockModelContainerUrl, erase: setupStep.url == nil, versionedSchema: setupStep.versionedSchema.self, data: migrationTest.setupStep.data)

        if let setupAction = setupStep.setupAction {
            setupAction(modelContainer)
            try modelContainer.mainContext.save()
        }
    }

    // Upgrade steps
    for upgradeStep in migrationTest.upgradeSteps {
        let modelContainer = try openModelContainer(url: defaultAppDataMockModelContainerUrl, versionedSchema: upgradeStep.versionedSchema, migrationPlan: upgradeStep.migrationPlan)

        if let setupAction = upgradeStep.setupAction {
            setupAction(modelContainer)
            try modelContainer.mainContext.save()
        }
    }

    try copyModelContainer(src: defaultAppDataMockModelContainerUrl, dst: defaultTmpAppDataMockModelContainerUrl)

    // Test
    do {
        let test = migrationTest.test

        let initialContainerModel = try openModelContainer(url: defaultTmpAppDataMockModelContainerUrl, versionedSchema: migrationTest.setupStep.versionedSchema.self, migrationPlan: migrationTest.setupStep.migrationPlan)

        let modelContainer = try openModelContainer(url: defaultAppDataMockModelContainerUrl, versionedSchema: test.versionedSchema, migrationPlan: test.migrationPlan)

        try test.validator(migrationTest, initialContainerModel, modelContainer)
    }

}

Using this abstraction layer, we can finally run tests to verify the integrity of the data in the model container after a SwiftData schema migration:

@MainActor
@Suite(.serialized)
struct AppDataMigration {
    @Test func V01toV05fromFile() throws {
        try runAppDataMigrationTest(.init(
            setupStep: .init(versionedSchema: AppDataSchemaV01.self, migrationPlan: AppDataMigrationPlanV01.self, url: getModuleContainerUrlFromTestBundle("app-model-v01-container")!),
            test: .init(
                versionedSchema: AppDataSchemaV05.self,
                migrationPlan: AppDataMigrationPlanV05.self,
                validator: {(test, initialModelContainer, modelContainer) in
                    // ...
                }
            )
        ))
    }

    @Test func V02toV05WithUpgradeStepFromV01() throws {
        try runAppDataMigrationTest(.init(
            setupStep: .init(versionedSchema: AppDataSchemaV01.self, migrationPlan: AppDataMigrationPlanV01.self, data: appDataMockDataSetV01),
            upgradeSteps: [
                .init(versionedSchema: AppDataSchemaV02.self, migrationPlan: AppDataMigrationPlanV02.self)
            ],
            test: .init(
                versionedSchema: AppDataSchemaV05.self,
                migrationPlan: AppDataMigrationPlanV05.self,
                validator: {(test, initialModelContainer, modelContainer) in
	                // ...
                }
            )
        ))
    }

    @Test func V03toV05() throws {
        try runAppDataMigrationTest(.init(
            setupStep: .init(versionedSchema: AppDataSchemaV03.self, migrationPlan: AppDataMigrationPlanV03.self, data: appDataMockDataSetV03),
            test: .init(
                versionedSchema: AppDataSchemaV05.self,
                migrationPlan: AppDataMigrationPlanV05.self,
                validator: {(test, initialModelContainer, modelContainer) in
	                //..
                }
            )
        ))
    }
}

Conclusion

Every project utilizing SwiftData should be prepared for testing and data migration, as it is nearly inevitable in the product lifecycle. This process is not overly complex, provided you follow consistent procedures and maintain a clear, versioned project structure.

The complete source code for this project is available on GitHub:: https://github.com/AndreiMaksimovich/SwiftDeveloperBlog/tree/main/06%20-%20SwiftData%20Migration