If you use CoreData then you will probably face the problem of database migration sooner or later. Automatic migration is turned on easily, but how can you be reasonably sure that your fresh app update will still be able to open databases using the old schema? Like when people were using the app and are now installing your update that needs more entities.
We were beginning to face this scenario in multiple apps, so we started unit testing CoreData automatic migrations like I’m going to show in this article.
Let’s quickly recap how automatic migration works.
CoreData Rehash
You started out with a single database schema and the app is installed on (hopefully) millions of user devices. When creating the CoreData stack you typically create:
- NSManagedObjectContext (one or more, those are basically the scratchpads you work with)
- NSManagedObjectModel (A representation of the data model, which contains all versions of the model)
- NSPersistantStore (The actual part that talks to the sqlite file)
Each sqlite file has a hash value that identifies the version of the DB schema. When connecting NSPersistantStore to the file you need to give it an NSManagedObjectModel that contains the version that matches this hash. We do not know how exactly the hash is being generated, but we do know that if you add an attribute to an entity you get a new hash. If you remove this added entity again, then the hash returns to the previous one. So it is not some form of random UUID but indeed gets generated from the schema itself. Another example we experienced ourselves: Adding an inverse to a relationship also does change the hash.
The above mentioned NSManagedObjectModel reads a binary representation of all model versions you have in your Xcode project. Inside your project you have a xcdatamodeld package that contains one xcdatamodel per version. Each of these is too a package, but only with one contents file that describes the model in plain-text XML. There was a time when these where binary too, but having them as XML allows versioning systems to diff the models which is extremely handy.
Automatic Migration
To add a new model version you select the xcdatamodeld and then in menu “Editor – Add Model Version”. Then you choose the new version as the current one, there’s an inspector section “Versioned Core Data Model”. You also see a green checkmark next to the current one.
Automatic Migration is disabled by default. I wonder why. Apple calls it Automatic Lightweight Migration to communicate that there are some more advanced changes that it cannot do automatically. But simple changes like adding optional attributes or relationships are no problem.
You need to pass some additional options to the persistent store via addPersistentStoreWithType:configuration:URL:options:error: as shown below.
NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys: [NSNumber numberWithBool:YES], NSMigratePersistentStoresAutomaticallyOption, [NSNumber numberWithBool:YES], NSInferMappingModelAutomaticallyOption, nil]; BOOL success = [psc addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:storeURL options:options error:&error]; |
These should cover the vast majority of scenarios. There are also more advanced ways of migrating but these are outside of the scope of this article.
Automatic Migration Testing
Typically developers would test automatic migration by first installing the earlier version of their app on a device. Then build&run the newer version from Xcode. If it doesn’t crash then they’d know that the migration worked.
This approach is fine for if you have few apps or few DB model versions. The more of both you have the more tedious the manual approach gets because in theory you can have many customers who never updated from the very first version of the app and then suddenly skip to the latest. Those still expect the app to continue to work and preserve their data.
Fortunately DB migrations can be test very easily. The approach would be to hold onto one sqlite file per model version. It does not matter if there is any data in it for basic testing, because you can see right away if the persistent store can be opened or if the store has an unknown model hash.
So the setup goes as follows:
- launch the app in simulator once from scratch. Find and copy the sqlite store file to your Unit Testing resources.
- repeat this for each model version.
- Add all these stores as resources to your unit test target.
- add your xcdatamodeld to the unit test target so that it will also get compiled and included there.
- Add a unit test case like shown below.
This unit test has a test for each model version and tests if this old file can still be opened with the freshly compiled model. This way you can be absolutely certain that you never accidentally make a change to your model that causes the hash to be different.
@implementation PersistenceStoreTest - (void)performTestWithStoreName:(NSString *)name { NSBundle *bundle = [NSBundle bundleForClass:[self class]]; NSURL *storeURL = [bundle URLForResource:name withExtension:@"sqlite"]; STAssertNotNil(storeURL, @"Cannot find %@.sqlite", name); NSURL *modelURL = [bundle URLForResource:@"OfflineModel" withExtension:@"momd"]; NSManagedObjectModel *managedObjectModel = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelURL]; STAssertNotNil(managedObjectModel, @"Cannot load model"); NSPersistentStoreCoordinator *persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:managedObjectModel]; NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys: [NSNumber numberWithBool:YES], NSMigratePersistentStoresAutomaticallyOption, [NSNumber numberWithBool:YES], NSInferMappingModelAutomaticallyOption, nil]; NSError *error; NSPersistentStore *persistentStore = [persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:storeURL options:options error:&error]; STAssertNotNil(persistentStore, @"Cannot load persistentStore: %@", [error localizedDescription]); } - (void)testOfflineStore { [self performTestWithStoreName:@"OfflineModel"]; } - (void)testOfflineStore_2 { [self performTestWithStoreName:@"OfflineModel-2"]; } @end |
The performTestWithStoreName is a helper method that executes the test for a given store file, you would add on of the test* methods per store file.
The beauty of this approach is that it is simple to add tests for any future model versions. Just copy a new store using the new model to your test resources and copy/paste the test. And even if there are no new versions then this test makes sure that the current merged model is still able to open all old stores.
Conclusion
Unit Tests are a great insurance for you to know for certain that changes you make to your code don’t cause problems for other pieces that rely on certain functionality. You have seen that it is very easy to also insure that DB changes don’t cause unwanted knock-on effects.
Save yourself the grief of finding out that an app update breaks your app for existing users because you botched automatic lightweight migration.
Categories: Recipes
Thanks, I modified this a little bit, though:
Iterate over [[NSBundle bundleForClass:[self class]] URLForResource:ModelFileName withExtension:@”momd”]; and find everything with the extension ‘mom’ and try to find the correct sqlite file for that. With this, you only need one testCoreDataMigration method for all model version.
well, provided that you name your stores the same as you model…