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:
- It's easy enough to set this up, and
- 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 toYES
in the "test kit" target in order to importXCTest
.
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 CoreDataTestCase
s. 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. 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.