Simplifying Core Data Unit Testing

August 28, 2021

This article looks at a technique for setting up unit tests where each test method executes against a completely isolated NSPersistentContainer backed by a SQLite persistent store.

I have successfully used this technique for nearly a decade. In 2018, Apple's WWDC session "Core Data Best Practices" (around the 30 minute mark) discusses this technique, too. However, Apple's example uses an in-memory SQLite store. So what's so great about using a SQLite persistent store that actually reads and writes to a SQLite database file? After all, Core Data is ostensibly already unit tested, so why go through the trouble?

Here's my two-fold answer:

  1. It's easy enough to set this up, and
  2. You get the benefit of knowing your production code actually works against the same production SQLite store.

Introducing CoreDataTestCase

Let's start with the basic skeleton of a XCTestCase subclass named CoreDataTestCase.

open class CoreDataTestCase<P: NSPersistentContainer>: XCTestCase {

    public private(set) var persistentContainer: P!

    public var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }

The test case exposes an instance of a NSPersistentContainer of type P. The CoreDataTestCase instantiates P using the NSPersistentContainer designated initializer. This allows for your test case subclass to instantiate any NSPersistentContainer type, including an app-specific subclass.

There's also a convenience property for grabbing the viewContext. It's safe for a test method to directly access managed objects in the viewContext because the test method executes on the main queue.

Test Target Bundle

The CoreDataTestCase assumes that the NSManagedObjectModel is loaded from a Bundle. By default this template method returns the test target bundle. Subclasses may override this template method to return a resource bundle containing the Core Data model to load into the persistent store. For example, a Swift Package resource bundle.

     // CoreDataTestCase

    open func targetBundle() -> Bundle {
        return .testTargetBundle
    }

Setting Up The NSPersistentContainer

The setUpWithError method is responsible for creating and loading the NSPersistentContainer. Remember the setUpWithError method executes for every test method in the test case instance. This means that every test method receives a completely independent NSPersistentContainer backed a SQLite NSPersistentStore pointing to a unique directory. By having the SQLite persistent store located in a unique directory means that each test method is guaranteed to execute in isolation from other tests.

     // CoreDataTestCase

    open override func setUpWithError() throws {
        try super.setUpWithError()

        // This instantiates a `NSPersistentContainer` of type `P`.
        persistentContainer = try .testCasePersistentContainer(
            forModelInBundle: targetBundle()
        )
    }
}

A new, unit test only, extension method on NSPersistentContainer utilizes the NSPersistentContainer extensions discussed in the A Technique For Creating a NSPersistentContainer article. Recall that the SQLitePersistentStoreDescriptionConfigurator sets the NSPersistentDescription/shouldAddStoreAsynchronously to false. This allows the test case to load the persistent store without additional asynchronous logic.

import XCTest

extension NSPersistentContainer {

    static func testCasePersistentContainer<P: NSPersistentContainer>(
        forModelInBundle bundle: Bundle
    ) throws -> P {
    
        // This is the same function discussed in the article mentioned above.
        //
        // Notice the `uniqueTemporaryDirectory()`.
        let persistentContainer: P = try makePersistentContainer(
            forModelsInBundle: bundle,
            configurator: SQLitePersistentStoreDescriptionConfigurator(
                directory: uniqueTemporaryDirectory(),
                name: bundle.name
            )
        )

        try persistentContainer.loadPersistentStoreOrFail()

        return persistentContainer
    }

    // It's this directory that provides SQLite database isolation.
    static private func uniqueTemporaryDirectory() -> URL {
        return FileManager.default
            .temporaryDirectory
            .appendingPathComponent(UUID().uuidString, isDirectory: true)
    }
    
    private func loadPersistentStoreOrFail() throws {
        loadPersistentStores { _, error in
            if let error = error {
                XCTFail(error.localizedDescription)
            }
        }
    }
}

The testCasePersistentContainer(forModelInBundle:) also loads the persistent store.

Tearing Down The NSPersistentContainer

The tearDownWithError method executes after the test method completes. Now is a good time to tear down the persistent container and remove the SQLite database files.

     // CoreDataTestCase
    
    open override func tearDownWithError() throws {
        try persistentContainer?.destroyPersistentStore()
        persistentContainer = nil
    
        try super.tearDownWithError()
    }

Another, unit test only, extension on NSPersistentContainer introduces a destroyPersistentStore function. The destroyPersistentStore function removes the SQLite persistent store from the coordinator and deletes the SQLite database files.

import XCTest

extension NSPersistentContainer {

    func destroyPersistentStore() throws {
        try persistentStoreCoordinator.performAndWait {
            let persistentStore = try unwrapPersistentStore()
            try persistentStoreCoordinator.remove(persistentStore)

            let fileURL = try unwrapPersistentStoreURL()
            try FileManager.default.removeItem(
                at: fileURL.deletingLastPathComponent()
            )   
        }
    }

    func unwrapPersistentStore() throws -> NSPersistentStore {
        return try XCTUnwrap(persistentStoreCoordinator.persistentStores.first)
    }

    func unwrapPersistentStoreURL() throws -> URL {
        return try XCTUnwrap(unwrapPersistentStore().url)
    }
}

A great option is to collect this type of API in a "test kit" library. You'll need to set the ENABLE_TESTING_SEARCH_PATHS build setting to YES in the "test kit" target in order to import XCTest.

Complete Class

Here's the complete CoreDataTestCase.

import CoreData
import XCTest

open class CoreDataTestCase<P: NSPersistentContainer>: XCTestCase {

    public private(set) var persistentContainer: P!

    public var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }

    open func targetBundle() -> Bundle {
        return .testTargetBundle
    }

    open override func setUpWithError() throws {
        try super.setUpWithError()

        continueAfterFailure = false
        
        persistentContainer = try .testCasePersistentContainer(
            forModelInBundle: targetBundle()
        )
    }

    open override func tearDownWithError() throws {
        try persistentContainer?.destroyPersistentStore()
        persistentContainer = nil

        try super.tearDownWithError()
    }
}

Writing Your First CoreDataTestCase Tests

Here's what a CoreDataTestCase subclass may look like. All test methods execute in isolation. Therefore you don't have to worry about rogue data in one test method interferring with another test method.

final class YourBusinessFeatureJSONWebResponseHandlerTest: CoreDataTestCase<NSPersistentContainer> {

    override func targetBundle() -> Bundle {
        return .yourLibraryBundle
    } 
    
    func testSomeContrivedExampleThatInsertsUpdatesAndDelegatesObjects() throws {
        // 1) standup async "data importer" that uses a background managed 
        //    object context.
        // 2) execute "data importer" with relevant new/updated/deleted data 
        //    (e.g. JSON to Core Data).
        // 3) wait for async "data importer" to complete, which includes saving 
        //    background context.
        // 4) fetch results into the `viewContext`.
        // 5) assert expected object graph (inserts, updates, deletes).
    }
    
    func testSomeContrivedExampleThatRemovesAllObjects() throws {
        // 1) standup async "data importer" that uses a background managed 
        //    object context (and insert appropriate objects).
        // 2) execute "data importer" with NO input data (e.g. JSON to Core Data).
        // 3) wait for async "data importer" to complete, which includes saving 
        //    background context.
        // 4) verify all objects were deleted (because NO data means to remove all).
    }
}

Testing With A NSPersistentContainer Subclass

Let's say you have a subclass of NSPersistentContainer named MyAppPersistentContainer that adds API specific to your app's needs.

final class MyAppPersistentContainer: NSPersistentContainer {

    // Custom API specific to your needs
    func doSomethingSpecificToMyApp() throws {
        // do stuff
    }
}

Here's how to tell the CoreDataTestCase to instantiate your MyAppPersistentContainer. Now you can use your app-specific API in your tests.

final class MyOtherTest: CoreDataTestCase<MyAppPersistentContainer> {

    override func targetBundle() -> Bundle {
        return .someLibrary
    }

    func testSomeContrivedExampleThatUsesAPIOnMyAppPersistentContainer() throws {
        try persistentContainer.doSomethingSpecificToMyApp()
    }
}

Convenience Subclass

You're going to have a lot of CoreDataTestCases. Therefore, you'll find it helpful to create an app-specific and/or package specific subclass of CoreDataTestCase that all of your test cases can subclass to eliminate boilerplate. Here's an example:

class SomeLibraryCoreDataTestCase: CoreDataTestCase<MyAppPersistentContainer> {

    override func targetBundle() -> Bundle {
        return .someLibrary
    }
}

Wrapping Up

A lot of software bugs happen at boundary layers where data is being imported and exported. For example, a lot of apps deal with web services returning JSON or XML that need to be transformed into Core Data managed objects. Or apps, such as my High Rail app, that transform Lionel, LLC proprietary TCP messages into Core Data managed objects. It's at these import/export boundary layers where unit testing helps ensure that the your app behaves correctly and, just as important, ensure that your app is protected from malicious and bad data.

The beauty of the CoreDataTestCase is that it encapsulates the logic for setting up and tearing down a NSPersistentContainer backed by a SQLite NSPersistentStore. This allows you to confidently write tests against production code that interacts with Core Data in complete isolation from other tests.

In the next article, we'll look at how to capture and attach the underlying SQLite database to a failed test. This enables you to open the Core Data generated SQLite database in your favorite database debugger tool. For quick and dirty debugging, there's sqlite3. For more advanced debugging there is DataGrip from JetBrains.