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
- Testing should cover updates from every previous SwiftData schema to the current one.
- Testing should validate the integrity of migrated data against the original data.
- We should be able to use previously saved model containers for testing.
- We should be able to use programmatically defined test data sets
Required Procedures
To achieve these goals, we should:
- Use
VersionedSchemafor each schema update. - Provide a
SchemaMigrationPlanfor 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