How do you test an asynchronous Core Data operation?
Testing Core Data has some challenges. Using an in-memory store helps but what if the operation you want to test happens asynchronously? One approach is to have the test listen for the notification Core Data sends when it saves changes.
Performing Asynchronously
Here’s my setup. I have a Country
managed object that amongst other properties has a boolean visited flag:
public final class Country: NSManagedObject {
@NSManaged public var visited: Bool
...
}
I also have a “store” class that owns my core data container and provides the view context:
public final class WorldStore: ObservableObject {
@Published public private(set) var error: Error?
public var viewContext: NSManagedObjectContext
private let dataContainer: CoreDataContainer
...
}
The visited
property defaults to false
for a new country. A method in the store allows me to toggle the value. I’m doing that in a perform
block and then saving the context:
public func toggleVisited(identifiedBy objectID: NSManagedObjectID) {
viewContext.perform {
if let country = self.viewContext.object(with: objectID) as? Country {
country.visited.toggle()
self.error = self.viewContext.saveIfChanged()
}
}
}
Testing Asynchronous Operations
The challenge with testing what happens inside the perform
block is that it’s happening asynchronously. Testing that method synchronously leads to disappointment:
func testToggleVisited() {
let country = Country(context: store.viewContext)
store.toggleVisited(identifiedBy: country.objectID)
XCTAssertTrue(country.visited)
}
After calling toggleVisited
the visited value should be true
but this test fails. We’re testing the visited value before the perform
block has done its work.
XCTestCase Expectations
You create tests for asynchronous operations using expectations. My first experience with test expectations was testing callback handlers. For example, testing a URLSession
network operation:
func testSessionData() {
let expect = expectation(description: "Session completion")
let task = session.dataTask(with: url) { data, response, error in
XCTAssertNotNil(data)
expect.fulfill()
}
task.resume()
waitForExpectations(timeout: 1)
}
After creating an expectation, you start the asynchronous operation and then wait. The test fails if the timeout expires before you fulfil the expectation by calling fulfill()
in the callback handler.
Expecting A Notification
There’s a different type of test expectation that works for notifications:
expectation(forNotification:object:handler:)
You create the expectation with a notification name and object to observe and an optional handler. When Core Data saves a managed object context it sends a notification. We can create an expectation that we fulfil when it receives the save notification for the managed object context we are using:
expectation(forNotification: .NSManagedObjectContextDidSave,
object: store.viewContext)
Use the optional handler if you need finer control over which notification fulfils the expectation:
expectation(forNotification: .NSManagedObjectContextDidSave,
object: store.viewContext) { notification in
if let objects = notification.userInfo?[NSInsertedObjectsKey] as? Set<Country>,
objects.first?.objectID == country.objectID {
return true
}
return false
}
With that knowledge we can write a passing test for our toggle visited method:
func testToggleVisited() throws {
let country = Country(context: store.viewContext)
expectation(forNotification: .NSManagedObjectContextDidSave,
object: store.viewContext)
store.toggleVisited(identifiedBy: country.objectID)
waitForExpectations(timeout: 1)
XCTAssertTrue(country.visited)
}
We now test the value of the visited property after we have fulfilled the expectation. Note we don’t need to call fulfill()
. The receipt of the save notification fulfils the expectation for us.