Writing Unit Tests for Core Data Migrations

We’ll explore how we can write unit tests for Core Data migrations. This approach should apply to both lightweight and heavyweight migrations.

You can find a complete example project in Github: UnitTestingCoreDataMigration.

Stack and Model Versions

We’re going to use the Core Data stack that’s generated automatically by Xcode. And then add two managed object models:

  • App V1: The first version which defines the BoardGame entity.
  • App V2: The next version which adds a new property, availableForPurchase, to the BoardGame entity.
App V1
App V2

The unit tests we will write will be for migrating from V1 to V2.

Unit Test Pattern

We will follow this pattern when writing the unit tests.

  1. Create an NSPersistentContainer using the App V1 managed object model.
  2. Perform any preconditions that we need for running assertions later.
  3. Migrate the App V1 NSPersistentContainer to App V2.
  4. Assert the state of the App V2 NSPersistentContainer.

One important aspect is that we will be using NSMigrationManager for step 3. And this will result in a new NSPersistentContainer instance and a new store file (SQLite). The old store file will be left untouched. So, we have to remember to run assertions on the new instance.

The Goal

Following the pattern, our goal is to write the following unit test.

func testMigratingFromV1ToV2AddsTheAvailableForPurchaseProperty() throws {
    // Given
    let sourceContainer = try startPersistentContainer("App V1")

    // Precondition: The `availableForPurchase` property does not exist in V1.
    let description =
        NSEntityDescription.entity(forEntityName: "BoardGame",
                                   in: sourceContainer.viewContext)!
    XCTAssertFalse(description.propertiesByName.keys.contains("availableForPurchase"))

    // When
    let targetContainer = try migrate(container: sourceContainer, to: "App V2")

    // Then
    // Validate that the `availableForPurchase` property is now available.
    let migratedDescription =
        NSEntityDescription.entity(forEntityName: "BoardGame",
                                   in: targetContainer.viewContext)!
    XCTAssertTrue(migratedDescription.propertiesByName.keys.contains("availableForPurchase"))
}

This unit test asserts that when migrating a store from V1 to V2, an availableForPurchase property gets added to the BoardGame entity.

Migration Functions

The unit test goal shared above uses a couple of reusable functions. You can find all the utility functions in CoreDataUtils.swift. But we’ll only discuss the most important ones here.

startPersistentContainer(_ versionName:)

The startPersistentContainer function is used in step 1 to create a temporary NSPersistentContainer. The NSPersistentContainer‘s persistent store will also be loaded to allow setting up of preconditions in step 2.

/// - Parameter versionName: The name of the model (`.xcdatamodel`) 
///                          to migrate to. For example, `"App V2"`.
func startPersistentContainer(_ versionName: String) throws -> NSPersistentContainer {
    let storeURL = makeTemporaryStoreURL()
    let model = managedObjectModel(versionName: versionName)

    let container = makePersistentContainer(storeURL: storeURL,
                                            managedObjectModel: model)
    container.loadPersistentStores { _, error in
        XCTAssertNil(error)
    }

    return container
}

migrate(container:to:)

The migrate function is used in step 3 to migrate from V1 to V2. As previously mentioned, we mainly use NSMigrationManager.migrateStore() to perform the migration.

The most important argument of migrateStore would probably be the mappingModel. Since the changes from V1 to V2 are simple (lightweight migration), we can use NSMappingModel.inferredMappingModel to let Core Data define how to migrate the V1 store to use the V2 managed object model.

If we had a more complex migration (heavyweight), we could create a custom mapping model and load it in this migrate() function.

/// - Parameter container: The `NSPersistentContainer` containing the 
///                        source store that will be migrated.
/// - Parameter versionName: The name of the model (`.xcdatamodel`) 
///                          to migrate to. For example, `"App V2"`.
func migrate(container: NSPersistentContainer, 
             to versionName: String) throws -> NSPersistentContainer {
    // Define the source and destination `NSManagedObjectModels`.
    let sourceModel = container.managedObjectModel
    let destinationModel = managedObjectModel(versionName: versionName)

    let sourceStoreURL = storeURL(from: container)
    // Create a new temporary store URL. This is where the migrated data 
    // using the model will be located.
    let destinationStoreURL = makeTemporaryStoreURL()

    // Infer a mapping model between the source and 
    // destination `NSManagedObjectModels`.
    // Modify this line if you use a custom mapping model.
    let mappingModel = try NSMappingModel.inferredMappingModel(
        forSourceModel: sourceModel,                                                               
        destinationModel: destinationModel
    )

    let migrationManager = NSMigrationManager(
        sourceModel: sourceModel,
        destinationModel: destinationModel
    )
    // Migrate the `sourceStoreURL` to `destinationStoreURL`.
    try migrationManager.migrateStore(from: sourceStoreURL,
                                      sourceType: storeType,
                                      options: nil,
                                      with: mappingModel,
                                      toDestinationURL: destinationStoreURL,
                                      destinationType: storeType,
                                      destinationOptions: nil)

    // Load the store at `destinationStoreURL` and return the 
    // migrated container.
    let destinationContainer = makePersistentContainer(
        storeURL: destinationStoreURL,
        managedObjectModel: destinationModel
    )
    destinationContainer.loadPersistentStores { _, error in
        XCTAssertNil(error)
    }

    return destinationContainer
}

With the startPersistentContainer and migrate functions, we now have everything we need to achieve the unit test goal shown above. We can also reuse these functions to write unit tests for future model versions (i.e., App V3). And we can follow the same unit test pattern.

Another Unit Test Example

Following the same unit test pattern and using the utility functions above, we can write another unit test for migrating from V1 to V2. This time, we’ll prove that the existing data is kept and migrated to V2.

func testMigratingFromV1ToV2KeepsTheExistingData() throws {
    // Given
    let sourceContainer = try startPersistentContainer("App V1")

    // Insert pre-migration data.
    insertBoardGame(name: "Chess", 
                    numberOfPlayers: 2, 
                    into: sourceContainer.viewContext)
    insertBoardGame(name: "Scrabble", 
                    numberOfPlayers: 4, 
                    into: sourceContainer.viewContext)

    try sourceContainer.viewContext.save()

    // When
    let targetContainer = try migrate(container: sourceContainer, 
                                      to: "App V2")

    // Then
    // Prove the existing `BoardGame` data is still there.
    XCTAssertEqual(try countOfBoardGames(in: targetContainer.viewContext), 2)

    // Prove that we can use the new `availableForPurchase` property
    let boardGame = insertBoardGame(name: "Monopoly",
                                    numberOfPlayers: 4,
                                    into: targetContainer.viewContext)
    boardGame.setValue(true, forKey: "availableForPurchase")

    XCTAssertNoThrow(try targetContainer.viewContext.save())
}

You can find view all these unit tests and the helper functions used in MigrationTests.swift.

Possibilities

Following the pattern and using the migration functions shown here, we can write more complex unit tests, especially for heavyweight migrations.