Can Unit Testing and Core Data become BFFs?
Core Data and Unit Testing haven't always been the best of friends. Like two members of the same friend group who don't really know each other but really like their UIKit
friend, Core Data and Unit Testing have in fact discovered that they have a lot in common and have gradually got more and more friendly with each other.
But before we delve into how they can take it one step further and become firm friends, we need to understand what makes each of them tick.
Getting to know each other
Core Data
Core Data is an object graph manager that helps you create the model layer of your app. An object graph is a collection of objects connected to each other through relationships. Core Data abstracts away the lifecycle of the objects it controls, providing tools to allow CRUD operations to be performed on those objects in its graph. In one configuration, Core Data's object graph can be persisted between executions of the app, with those objects being persisted in either XML, binary, or SQLite stores. However in another configuration, the object graph exists purely in-memory and dies when the app is killed. In fact, the range of configuration options available to Core Data makes it very difficult to define what Core Data actually is - in one configuration Core Data can resemble a SQL wrapper while in a different configuration it looks more like an ORM. The truth is that Core Data sits somewhere between both, with the ability to intelligently load a subset of its store's data into memory as custom domain objects that can be manipulated like any other Swift object before being "saved" back to the store where those changes are then persistent (be that across app executions or just in that app execution).
A consequence of this flexibility is it's not uncommon to see two apps using Core Data in significantly different ways. This has led to Core Data gaining a reputation as a hard to use framework (not helped by the significant changes that the framework has gone through since inception). To avoid any confusion around what configuration of Core Data we are going to use, in this post our setup will look like:
Persistent Container
is a fairly new member of the Core Data family. It was introduced in iOS 10 to help simplify the creation of the managed object model, persistent store coordinator and any managed object contexts.Managed Object Context
is a temporary, in-memory record of allNSManagedObject
instances accessed, created or updated during its lifecycle. An app will typically have multiple contexts in existence at any one given time. These contexts will form a parent-child relationship. When a child context is saved it will push its changes to its parent's context which will then merge these changes into its own state. At the top of this parent-child hierarchy is themain
context, this context upon being saved will push its changes into the persistent store.Persistent Store Coordinator
acts as an aggregator between the various different contexts to ensure the integrity of the persistent store(s). It does this by serialising read/write operations from the contexts to its store(s). There is only one coordinator per Core Data stack.Managed Object Model
is a set of entities that define eachNSManagedObject
subclass. An entity can be thought of as a table in a database.Persistent Object Store
is an abstraction over the actual storage of our data. Handles communication with that storage e.g. with SQLite storage, converts fetch requests into SQL statements.Storage
it's common for this to be a SQLite store but the store could also be: XML, binary and in-memory.
Unit Testing
Unit Testing is ensuring that the smallest part (unit) of testable code in your app behaves as expected in isolation (from the other parts of your app). In object-oriented programming, a unit is often a particular method, with the unit test testing one scenario (path) through that method. A unit test does this by providing a strict, written contract that the unit under test must satisfy in order to pass. If there are multiple paths through a unit i.e. an if
and else
branch, then more than one unit test would be required to cover each path.
Unit tests are then combined into a test suite within a test target/project, this suite can then be run to give an increased level of confidence in that the app classes are valid.
Each unit test should be executed in isolation from other unit tests to ensure that the failure of a previous test has no impact upon the next test. It is up to the unit test to ensure that any conditions (test data, user permissions, etc) that it depends on are present before the test is run and it has to tidy up after itself when it is finished. This helps to ensure that the unit test is repeatable and not dependent upon any state on the host environment. The unit test is also responsible for ensuring that the unit under test is isolated from other methods within the app and that any calls (relationship) it makes for information with other methods are mocked out. A mocked method will then return a known, preset value without performing any computation so that if a unit test fails we can have confidence that it has failed because of the code under test rather than having to hunt down the failure in its dependencies.
Each unit test should be as quick as possible to run to ensure that during development, the feedback loop between running the unit test and making code changes is as small as possible.
Building that friendship
From the above descriptions, we can see why Core Data and Unit Testing didn't instantly hit it off. Their differences centre on two issues:
Treatment of data
- Unit Testing treats data as ephemeral
- The main use case of Core Data is persisting data between app executions
Tolerance for delays
- Unit tests should be lightning quick to execute
- Core Data typically has to communicate with a SQLite file on disk which is slow (when compared with pure memory operations)
And like building any good relationship, all the changes will come from one side 😜 - Core Data.
(Ok ok, I'm sketching a little bit now so please don't take that as genuine relationship advice)
CoreDataManager
Let's build a typical Core Data stack together and unit test it as we go.
If you want to follow along, head over to my repo and download the completed project.
Let's start with the manager that will handle setting up our Core Data stack:
class CoreDataManager {
// MARK: - Singleton
static let shared = CoreDataManager()
}
We could add a unit test here and assert that the same instance of CoreDataManager
was always returned when shared
was called however when unit testing we should only test code that we control and the logic behind creating a singleton is handled by Swift itself - so no need to create that test class yet.
Unit tests while asserting the correctness of an implementation also act as a living form of documentation so if you did want to add a unit test to assert the same instance was returned, I won't put up too much of a fight.
As our project is being developed with an iOS deployment target of iOS 11 we can use the persistent container to simplify the Core Data stack's setup.
lazy var persistentContainer: NSPersistentContainer! = {
let persistentContainer = NSPersistentContainer(name: "TestingWithCoreData_Example")
return persistentContainer
}()
In the above code snippet, we added a lazy property to create an instance of NSPersistentContainer
. Loading a Core Data stack can be a time-consuming task especially if migration is required. To handle this we need to add a dedicated asynchronous setup method that can handle any time-consuming tasks.
While the below method doesn't actually show the migration itself, I think it's important to create and test a realistic Core Data stack and data migrations are very much a common task in iOS apps.
// MARK: - SetUp
func setup(completion: (() -> Void)?) {
loadPersistentStore {
completion?()
}
}
// MARK: - Loading
private func loadPersistentStore(completion: @escaping () -> Void) {
//handle data migration on a different thread/queue here
persistentContainer.loadPersistentStores { description, error in
guard error == nil else {
fatalError("was unable to load store \(error!)")
}
completion()
}
}
Ok so now we have something to test - does calling setup
actually set up our stack. Let's create that unit test class:
class CoreDataManagerTests: XCTestCase {
// MARK: Properties
var sut: CoreDataManager!
// MARK: - Lifecycle
override func setUp() {
super.setUp()
sut = CoreDataManager()
}
// MARK: - Tests
// MARK: Setup
func test_setup_completionCalled() {
let setupExpectation = expectation(description: "set up completion called")
sut.setup() {
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0, handler: nil)
}
}
In the above class, we declare a property sut
(s
ubject u
nder t
est) which will hold a CoreDataManager
instance that we will be testing. I prefer to use sut
as it makes it immediately obvious which object we are testing and which objects are collaborators/dependencies. It's important to note that the sut
property is an implicitly unwrapped optional - it's purely to make our unit tests more readable by avoiding having to handle it's optional nature elsewhere and is a technique that I would not recommend using too widely in production code. The test suite's setUp
method is where the CoreDataManager
instance is being created and assigned to sut
.
Let's take a closer look at the unit test itself:
func test_setup_completionCalled() {
let setupExpectation = expectation(description: "set up completion called")
sut.setup() {
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0, handler: nil)
}
When it comes to naming I follow the naming convention:
test_[unit under test]_[condition]_[expected outcome]
[condition]
is optional.
So the test method signature tells us that we are testing the setup
method and that the completion
closure should be triggered.
Now with this test, we are really jumping straight into the deeper end of unit testing by testing an asynchronous method but as we can see the code isn't actually that difficult to understand. The first thing we do is create an XCTestExpectation
instance, it's important to note here that we are not directly creating an XCTestExpectation
instance using XCTestExpectation
's init method instead we are using the convenience method provided by XCTestCase
. By creating it via XCTestCase
we will tie both the XCTestExpectation
and XCTestCase
together which will allow us to use waitForExpectations
and cut down on some of the boilerplate required with expectations. If you have never used expectations before, you can think of them as promising that an action will happen within a certain time frame. Sadly like actual promises, they can be broken and when they are - the test fails.
As I'm sure you have noted, test_setup_completionCalled
doesn't actually contain any asserts, this is because we are using the expectation as an implicit assert.
So we've tested that the completion
closure is called but we haven't actually checked that anything was set up. A successful set up should result in our persistent store being loaded so let's add a test to check that:
func test_setup_persistentStoreCreated() {
let setupExpectation = expectation(description: "set up completion called")
sut.setup() {
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0) { (_) in
XCTAssertTrue(self.sut.persistentContainer.persistentStoreCoordinator.persistentStores.count > 0)
}
}
As we can see test_setup_persistentStoreCreated
contains an assert to check that the persistentStoreCoordinator
has at least one persistentStore
. It's important to note that as persistentContainer
is lazy loaded merely checking that it's not nil wouldn't be a valid test as calling the property would result in creating the persistentContainer
.
The two unit tests that we have added are very similar and as you can see it's possible for test_setup_persistentStoreCreated
to fail for two reasons:
- Completion closure not triggered
- Persistent store not created
The first reason is actually being tested in test_setup_completionCalled
so why have I created another test that's dependent on it here? The reason is that it's actually impossible not to check this condition as it's an implicit dependency on any test that uses this method. Now the argument could be made that these two tests should be one - effectively a test_setup_stackCreated
test. I opted for two tests as I felt that it improved the readability in the event that one of those tests failed by providing a higher level of granularity for that happening. You sometimes hear people saying that a unit test should only ever have one assert and that any unit test that has more than one assert is wrong. IMHO, this is foolhardy. There are very few hard and fast rules in life, just about everything is context based - in this context having two asserts (one implicit, one explicit) in test_setup_persistentStoreCreated
makes sense as both asserts are checking that the same unit of functionality is correct.
Now, the more eagled eyed 👀 among you will have spotted that we are using the default storage type for our Core Data stack (NSSQLiteStoreType
) in the above tests - this creates a SQLite file on the disk and as we know any I/O operation is going to be much slower than a pure in-memory operation. It would be great if we could tell the Core Data stack which storage type to use - NSSQLiteStoreType
for production and NSInMemoryStoreType
for testing:
class CoreDataManager {
private var storeType: String!
lazy var persistentContainer: NSPersistentContainer! = {
let persistentContainer = NSPersistentContainer(name: "TestingWithCoreData_Example")
let description = persistentContainer.persistentStoreDescriptions.first
description?.type = storeType
return persistentContainer
}()
// MARK: - Singleton
static let shared = CoreDataManager()
// MARK: - SetUp
func setup(storeType: String = NSSQLiteStoreType, completion: (() -> Void)?) {
self.storeType = storeType
loadPersistentStore {
completion?()
}
}
// MARK: - Loading
private func loadPersistentStore(completion: @escaping () -> Void) {
persistentContainer.loadPersistentStores { description, error in
guard error == nil else {
fatalError("was unable to load store \(error!)")
}
completion()
}
}
}
As a long-term Objective-C iOS developer, I still get a thrill about being able to provide a default value to a parameter (like we do with the storeType
parameter above) without having to create a whole new method. This change will allow us to use the much quicker NSInMemoryStoreType
storage type in our tests while keeping a simple interface in production. However, it's not free and because we have introduced a new way of setting up our Core Data stack we need to update our existing tests to test this new path:
class CoreDataManagerTests: XCTestCase {
// MARK: Properties
var sut: CoreDataManager!
// MARK: - Lifecycle
override func setUp() {
super.setUp()
sut = CoreDataManager()
}
// MARK: - Tests
// MARK: Setup
func test_setup_completionCalled() {
let setupExpectation = expectation(description: "set up completion called")
sut.setup(storeType: NSInMemoryStoreType) {
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0, handler: nil)
}
func test_setup_persistentStoreCreated() {
let setupExpectation = expectation(description: "set up completion called")
sut.setup(storeType: NSInMemoryStoreType) {
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0) { (_) in
XCTAssertTrue(self.sut.persistentContainer.persistentStoreCoordinator.persistentStores.count > 0)
}
}
func test_setup_persistentContainerLoadedOnDisk() {
let setupExpectation = expectation(description: "set up completion called")
sut.setup {
XCTAssertEqual(self.sut.persistentContainer.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0) { (_) in
self.sut.persistentContainer.destroyPersistentStore()
}
}
func test_setup_persistentContainerLoadedInMemory() {
let setupExpectation = expectation(description: "set up completion called")
sut.setup(storeType: NSInMemoryStoreType) {
XCTAssertEqual(self.sut.persistentContainer.persistentStoreDescriptions.first?.type, NSInMemoryStoreType)
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0, handler: nil)
}
}
If we run the above tests we are able to see the difference in running times between test_setup_persistentContainerLoadedInMemory
and test_setup_persistentContainerLoadedOnDisk
:
As you can see on the above execution of both tests, loading the store on disk took 17 times longer than loading the store into memory - 0.001
vs 0.017
seconds.
In real terms, this speed increase isn't much on its own but once we start adding in tests that create, update and delete NSManagedObject
instances dealing with an in-memory store will allow these tests to be executed faster than if we used an on-disk store.
So far, we have made great progress on producing a Core Data stack that is unit testable but a Core Data stack without a context (or two) isn't going to be very useful - let's add some:
lazy var backgroundContext: NSManagedObjectContext = {
let context = self.persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}()
lazy var mainContext: NSManagedObjectContext = {
let context = self.persistentContainer.viewContext
context.automaticallyMergesChangesFromParent = true
return context
}()
And we need to add some unit tests for them:
func test_backgroundContext_concurrencyType() {
let setupExpectation = expectation(description: "background context")
sut.setup(storeType: NSInMemoryStoreType) {
XCTAssertEqual(self.sut.backgroundContext.concurrencyType, .privateQueueConcurrencyType)
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0, handler: nil)
}
func test_mainContext_concurrencyType() {
let setupExpectation = expectation(description: "main context")
sut.setup(storeType: NSInMemoryStoreType) {
XCTAssertEqual(self.sut.mainContext.concurrencyType, .mainQueueConcurrencyType)
setupExpectation.fulfill()
}
waitForExpectations(timeout: 1.0, handler: nil)
}
Good news is that we have finished creating and testing our Core Data stack and I think it wasn't actually too difficult 🎉.
Introducing ColorsDataManager
There is no point in creating a Core Data stack if we don't actually use it. In the example project we populate a collectionview with instances of a subclass of NSManagedObject
- Color
. To help us deal with these Color
objects we will be using a ColorsDataManager
:
class ColorsDataManager {
let backgroundContext: NSManagedObjectContext
// MARK: - Init
init(backgroundContext: NSManagedObjectContext = CoreDataManager.shared.backgroundContext) {
self.backgroundContext = backgroundContext
}
// MARK: - Create
func createColor() {
backgroundContext.performAndWait {
let color = NSEntityDescription.insertNewObject(forEntityName: Color.className, into: backgroundContext) as! Color
color.hex = UIColor.random.hexString
color.dateCreated = Date()
try? backgroundContext.save()
}
}
// MARK: - Deletion
func deleteColor(color: Color) {
let objectID = color.objectID
backgroundContext.performAndWait {
if let colorInContext = try? backgroundContext.existingObject(with: objectID) {
backgroundContext.delete(colorInContext)
try? backgroundContext.save()
}
}
}
}
In the above class, we have a simple manager that handles creating and deleting Color
instances. As a responsible member of the Core Data community, our example app treats the backgroundContext
as a read-write context and the mainContext
as a read-only context - this is to ensure that any time-consuming tasks don't block the main (UI) thread. You may have noticed that the above class doesn't actually contain any mention of CoreDataManager
instead this class only knows about the background context. By injecting this context into the class, we are able to decouple ColorsDataManager
from CoreDataManager
which should allow us to more easily test ColorsDataManager
😉.
Let's look at implementing our first test for ColorsDataManager
:
class ColorsDataManagerTests: XCTestCase {
// MARK: Properties
var sut: ColorsDataManager!
var coreDataStack: CoreDataTestStack!
// MARK: - Lifecycle
override func setUp() {
super.setUp()
coreDataStack = CoreDataTestStack()
sut = ColorsDataManager(backgroundContext: coreDataStack.backgroundContext)
}
// MARK: - Tests
// MARK: Init
func test_init_contexts() {
XCTAssertEqual(sut.backgroundContext, coreDataStack.backgroundContext)
}
}
As we can see, the majority of the above class is taken up with setting up the test suite. The test itself, merely checks that the context that we pass into ColorsDataManager
is the same context that is assigned to the backgroundContext
property.
You may also have noticed that coreDataStack
isn't a CoreDataManager
instance but instead a CoreDataTestStack
instance.
Let's go on a slight detour and have a look at CoreDataTestStack
:
class CoreDataTestStack {
let persistentContainer: NSPersistentContainer
let backgroundContext: NSManagedObjectContextSpy
let mainContext: NSManagedObjectContextSpy
init() {
persistentContainer = NSPersistentContainer(name: "TestingWithCoreData_Example")
let description = persistentContainer.persistentStoreDescriptions.first
description?.type = NSInMemoryStoreType
persistentContainer.loadPersistentStores { description, error in
guard error == nil else {
fatalError("was unable to load store \(error!)")
}
}
mainContext = NSManagedObjectContextSpy(concurrencyType: .mainQueueConcurrencyType)
mainContext.automaticallyMergesChangesFromParent = true
mainContext.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator
backgroundContext = NSManagedObjectContextSpy(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
backgroundContext.parent = self.mainContext
}
}
CoreDataTestStack
is very similar to CoreDataManager
but with its asynchronous setup behaviour stripped out and the storeType
always set to NSInMemoryStoreType
. This class allows us to more easily set up and tear down the stack between tests without having to wait on any asynchronous tasks to complete. Another difference between CoreDataTestStack
and CoreDataManager
is that the two contexts that are being created are not in fact standard NSManagedObjectContext
instances but are actually NSManagedObjectContextSpy
instances.
If you are curious as to what a
spy
is, Martin Fowler has produced a very insightful article on naming test objects - it's in the section titled:The Difference Between Mocks and Stubs
.
NSManagedObjectContextSpy
is a subclass of NSManagedObjectContext
that adds special state tracking properties. System classes occupy a grey area when it comes to if you should use them in your tests or if you need to replace them with mock/stub instances. In this case, I felt that mocking out a context's functionality would be too much work and would actually be counter-productive to what the test is attempting to achieve so I'm perfectly happy to use it directly.
class NSManagedObjectContextSpy: NSManagedObjectContext {
var expectation: XCTestExpectation?
var saveWasCalled = false
// MARK: - Perform
override func performAndWait(_ block: () -> Void) {
super.performAndWait(block)
expectation?.fulfill()
}
// MARK: - Save
override func save() throws {
save()
saveWasCalled = true
}
}
Ok, detour over. Let's get back to testing the ColorsDataManager
class:
func test_createColor_colorCreated() {
let performAndWaitExpectation = expectation(description: "background perform and wait")
coreDataStack.backgroundContext.expectation = performAndWaitExpectation
sut.createColor()
waitForExpectations(timeout: 1) { (_) in
let request = NSFetchRequest.init(entityName: Color.className)
let colors = try! self.coreDataStack.backgroundContext.fetch(request)
guard let color = colors.first else {
XCTFail("color missing")
return
}
XCTAssertEqual(colors.count, 1)
XCTAssertNotNil(color.hex)
XCTAssertEqual(color.dateCreated?.timeIntervalSinceNow ?? 0, Date().timeIntervalSinceNow, accuracy: 0.1)
XCTAssertTrue(self.coreDataStack.backgroundContext.saveWasCalled)
}
}
There is a bit more happening here than in the previous test that we saw. We create an XCTestExpectation
instance that we then assign to the expectation
property on the context. As we have seen above this expectation should be fulfilled when performAndWait
is called. Once that expectation has been triggered, we then check that the Color
instance was created and saved into our persistent store.
Testing the deletion of a Color
follows a similar pattern:
func test_deleteColor_colorDeleted() {
let performAndWaitExpectation = expectation(description: "background perform and wait")
coreDataStack.backgroundContext.expectation = performAndWaitExpectation
let colorA = NSEntityDescription.insertNewObject(forEntityName: Color.className, into: self.coreDataStack.backgroundContext) as! Color
let colorB = NSEntityDescription.insertNewObject(forEntityName: Color.className, into: self.coreDataStack.backgroundContext) as! Color
let colorC = NSEntityDescription.insertNewObject(forEntityName: Color.className, into: self.coreDataStack.backgroundContext) as! Color
sut.deleteColor(color: colorB)
waitForExpectations(timeout: 1) { (_) in
let request = NSFetchRequest.init(entityName: Color.className)
let backgroundContextColors = try! self.coreDataStack.backgroundContext.fetch(request)
XCTAssertEqual(backgroundContextColors.count, 2)
XCTAssertTrue(backgroundContextColors.contains(colorA))
XCTAssertTrue(backgroundContextColors.contains(colorC))
XCTAssertTrue(self.coreDataStack.backgroundContext.saveWasCalled)
}
}
We first populate our persistent (in-memory) store, call the deleteColor
method and then check that the correct Color
has been deleted. There is one special case - because we read on the main context and delete on the background context, the color
instance passed into this method may be from the main context, the above test is not covering this case so let's add another test that does:
func test_deleteColor_switchingContexts_colorDeleted() {
let performAndWaitExpectation = expectation(description: "background perform and wait")
coreDataStack.backgroundContext.expectation = performAndWaitExpectation
let colorA = NSEntityDescription.insertNewObject(forEntityName: Color.className, into: self.coreDataStack.backgroundContext) as! Color
let colorB = NSEntityDescription.insertNewObject(forEntityName: Color.className, into: self.coreDataStack.backgroundContext) as! Color
let colorC = NSEntityDescription.insertNewObject(forEntityName: Color.className, into: self.coreDataStack.backgroundContext) as! Color
let mainContextColor = coreDataStack.mainContext.object(with: colorB.objectID) as! Color
sut.deleteColor(color: mainContextColor)
waitForExpectations(timeout: 1) { (_) in
let request = NSFetchRequest.init(entityName: Color.className)
let backgroundContextColors = try! self.coreDataStack.backgroundContext.fetch(request)
XCTAssertEqual(backgroundContextColors.count, 2)
XCTAssertTrue(backgroundContextColors.contains(colorA))
XCTAssertTrue(backgroundContextColors.contains(colorC))
XCTAssertTrue(self.coreDataStack.backgroundContext.saveWasCalled)
}
}
Pretty much the same as before with the only difference being that we retrieve the color
to be deleted from the main context before passing that in.
An interesting point to note when adding those three tests is that we haven't had to add any code to clear our persistent store. This is because by using an NSInMemoryStoreType
store and ensuring that we create a new stack before each test - we never actually persist data. Not only does this save us time having to write the tidy up code, it also removes a whole category of bugs where leftover state from one test affects the outcome of another due to faulty/missing clean up code.
To come back to the point about in-memory stores being quicker to use than on-disk stores, we can see a typical difference in running times for the above tests below:
In-memory store
SQLite store
Best Friends Forever?
In the above code snippets, we have seen that unit testing with Core Data doesn't need to be that much more difficult than unit testing in general, with most of that added difficulty coming in the set up of the Core Data stack. And while Core Data and Unit Testing may not become BFFs (let's be honest UIKit
has that position sealed down), we've seen that they can become firm friends 👫. That friendship is built on small alterations to our Core Data stack which allows its data to be more easily thrown away and the use of special subclasses to allow us to better track state.
You can download the example project for this post here.