Async Core Data Testing

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.