Sunday, January 10, 2010

Unit testing Core Data-driven apps, fit the second

It took longer than I expected to follow up my previous article on unit testing and Core Data, but here it is.

Note that the pattern presented last time, Remove the Core Data Dependence, is by far my preferred option. If a part of your code doesn't really depend on managed objects and suchlike, it shouldn't need them to be present just because it works with (or in) classes that do. The following pattern is recommended only when you aren't able to abstract away the Core Data-ness of the code under test.

Pattern 2: construct an in-memory Core Data stack. The unit test classes you develop ought to have these, seemingly contradictory properties:

  • no dependence on external state: the tests must run the same way every time they run. That means that the environment for each test must be controlled exactly; dependence on "live" application support files, document files or the user defaults are all no-nos.

  • close approximation to the application environment: you're interested in how your app runs, not how nice a unit test suite you can create.


To satisfy both of these properties simultaneously, construct a Core Data stack in the test suite which behaves in the same way but which does not use the persistent store (i.e. document files) used by the real app. My preference is to use the in-memory store type, so that every time it is created it is guaranteed to have no reference to any prior state (unlike a file-backed store type, where you have to rely on unlinking the document files and hoping there are no timing issues in the test framework which might cause two tests simultaneously to use the same file).

My test case class interface looks like this (note that this is for a dependent test case bundle that gets embedded into the app; there's an important reason for that which I'll come to later). The managed object context will be needed in the test methods to insert new objects, I don't (yet) need any of the other objects to be visible inside the tests but the same objects must be used in -setUp and -tearDown.

#import <SenTestingKit/SenTestingKit.h>

@interface SomeCoreDataTests : SenTestCase {
NSPersistentStoreCoordinator *coord;
NSManagedObjectContext *ctx;
NSManagedObjectModel *model;
NSPersistentStore *store;
}

@end


The environment for the tests is configured thus. I would have all of the error reporting done in tests, rather than that one lone assertion in -tearDown, because the SenTest framework doesn't report properly on assertion failures in that method or in -setUp. So the -testThatEnvironmentWorks test method is a bellwether for the test environment being properly set up, but obviously can't test the results of tear-down because the environment hasn't been torn down when it runs.


#import "TuneNeedsHighlightingTests.h"

@implementation TuneNeedsHighlightingTests

- (void)setUp
{
model = [[NSManagedObjectModel mergedModelFromBundles: nil] retain];
NSLog(@"model: %@", model);
coord = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel: model];
store = [coord addPersistentStoreWithType: NSInMemoryStoreType
configuration: nil
URL: nil
options: nil
error: NULL];
ctx = [[NSManagedObjectContext alloc] init];
[ctx setPersistentStoreCoordinator: coord];
}

- (void)tearDown
{
[ctx release];
ctx = nil;
NSError *error = nil;
STAssertTrue([coord removePersistentStore: store error: &error],
@"couldn't remove persistent store: %@", error);
store = nil;
[coord release];
coord = nil;
[model release];
model = nil;
}

- (void)testThatEnvironmentWorks
{
STAssertNotNil(store, @"no persistent store");
}
@end


The important part is in setting up the managed object model. In using [NSManagedObjectModel mergedModelFromBundles: nil], we get the managed object model derived from loading all MOMs in the main bundle—remembering that this is an injected test framework, that's the application bundle. In other words the MOM is the same as that created by the app delegate. We get to use the in-memory store as a clean slate every time through, but otherwise the entity definitions and behaviours ought to be identical to those provided by the real app.

6 comments:

日不落 said...

Necessity is the mother of invention..........................

Chad said...

Very helpful post. I'm just beginning my first iPhone app, first objective-c even and I'm determined to use TDD on this app. I've started down the path of a separate UnitTest target that is invoked by my CI system as well as when I build.

The statement

model = [[NSManagedObjectModel mergedModelFromBundles:nil] retain];

doesn't return the model even though I've included the xcdatamodel in the target. How else do I reference the model in the real app?

Thanks

leeg said...

Sorry to have left it so long to reply. If you have a separate unit test target, then it's going to run in the context of the ocunit binary, not your app - so you can't look up MOMs in your app's bundle. You'll need to add the MOM to the unit test target and load it from the test target's bundle:

model = [[NSManagedObjectModel mergedModelFromBundles: [NSBundle bundleForClass: [self class]] retain];

(Note I typed that directly into the comment field, I haven't checked it)

Mark Rutter said...

Thanks. I tried it and found the bundles need to be an NSArray* like so:

NSArray *bundles = [NSArray arrayWithObject:[NSBundle bundleForClass:[self class]]];

model = [[NSManagedObjectModel mergedModelFromBundles:bundles] retain];

I'm not sure why using nil for bundles doesn't work, but this saved me a lot of head scratching.

Anonymous said...

Remember to include your xcdatamodel in your test target!

Took me a little while to figure this out. Hopefully it will help someone else.

Pradnya said...

i am getting exc_bad_access on the line =>

model = [[NSManagedObjectModel mergedModelFromBundles: nil]

i am using a new target for testing and ghunit as testing framework..

i have also included xcdatamodel in the test target

cannot figure out what is the problem :(