Unit Testing Core Data in iOS

Testing code is a crucial part of app development, and Core Data is not exempt from this. This tutorial will teach you how you can test Core Data. By Graham Connolly.

Leave a rating/review
Download materials
Save for later
Share

Testing your code is a crucial part of your app development journey. Although testing initially takes some time getting used to, it comes with a long list of benefits such as:

  • Allowing you to make changes without having to worry that parts of your app will break.
  • Speeding up debugging sessions.
  • Forcing you to think about how to structure your code in a more organized manner.

And in this tutorial, you’ll learn how to apply the benefits of testing to your Core Data Models.

You’ll work with PandemicReport, a simple but excellent pandemic report tracker. You’ll focus on writing unit tests for the project’s Core Data Model, and along the way you’ll learn:

  • What Unit Testing is and why it’s important.
  • How to write a Core Data Stack suitable for testing.
  • How to Unit Test your Core Data Models.
  • About TDD Methodologies.
Note: This tutorial assumes you know the basics of Core Data. If you’re new to Core Data, check out the Getting Started with Core Data Tutorial first.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial. Inside the starter project you’ll find PandemicTracker, an app that displays a list of infection reports.

You can add new reports and edit existing ones. The app persists the reports from session to session using Core Data. Build and run to check out the app.

Main Screen of App with Empty List

The app shows a list of pandemic reports saved in Core Data. Currently, you have no reports. Add one by clicking the add button in the navigation bar.

Pandemic Report Entry Form

Then, add a report entry by entering values into the text fields.

Pandemic Report form with values

Next, tap Save to save your report to Core Data and dismiss this screen.

List Contains Your Entry

The list now contains your entry.

In Xcode, look at the main files you’ll work on:

  • CoreDataStack.swift: An object wrapper for managing your app’s Core Data model layer.
  • ReportService.swift: Manages the app’s business logic.
  • ViewController.swift: Displays a list of reports saved in Core Data. Tapping + displays an entry form for a new report.
  • ReportDetailsTableViewController.swift: Shows the selected report’s details and lets you edit existing values. This also acts as an input form when you tap + in ViewController.

You’ll explore why writing unit tests for Core Data can be trickier than it sounds in the next few sections.

What is Unit Testing?

Unit Testing is the task of breaking down a project into smaller, testable pieces of code. For example, you could break down the logic of Messages on the iPhone into smaller units of functionality like this:

  • Assigning a recipient, or recipients, to the message.
  • Writing the text in the text area.
  • Adding an emoji.
  • Adding an image.
  • Attaching a GIF.
  • Attaching an Animoji.

While this may seem like a lot of extra work, there are many benefits of testing:

  • A unit test verifies your code works as intended.
  • Writing tests can catch bugs before they go into production.
  • Tests also act as documentation for other developers.
  • Unit tests save time when compared to manual testing.
  • A failed test during development lets you know something is broken.

In iOS, unit tests run in the same environment as the app you’re testing. As a result, this can lead to problems if a running test modifies the app’s state.

Note: If you’re getting started with testing in iOS, or would like a refresher, check out our tutorial on iOS Unit Testing and UI Testing.

CoreData Stack for Testing

The project’s Core Data stack currently uses a SQLite database as its storage. When running tests, you don’t want test or dummy data interfering with your app’s storage.

To write good unit tests, follow the acronym FIRST:

  • Fast: Unit Tests run quickly.
  • Isolated: They should function independently of other tests.
  • Repeatable: A test should produce the same results every time it’s executed.
  • Self-verifying: A test should either pass or fail. You shouldn’t need to check the console or a log file to determine if the test has succeeded.
  • Timely: Write your tests first so they can act as a blueprint for the functionality you add.

Core Data writes and saves data to a database file on a simulator or device. Since one test may overwrite the contents of another test you can’t consider them Isolated.

Since the data saves to disk, the data in your database grows over time and the state of the environment may be different on each test run. As a result, those tests aren’t Repeatable.

After a test finishes, deleting and then recreating the contents of the database isn’t Fast.

You might think, “Well, I guess I can’t test Core Data because it’s not testable”. Think again.

The solution is to create a Core Data stack subclass that uses an in-memory store rather than the current SQLite store. Because an in-memory store isn’t persisted to disk, when the test finishes executing, the in-memory store releases its data.

You’ll create this subclass in the next section.

Adding the TestCoreDataStack

First up, create a subclass of CoreDataStack under the PandemicReportTests group and name it TestCoreDataStack.swift.

Creating Adding the TestCoreDataStack

Next, add the following to the file:

import CoreData
import PandemicReport

class TestCoreDataStack: CoreDataStack {
  override init() {
    super.init()

    // 1
    let persistentStoreDescription = NSPersistentStoreDescription()
    persistentStoreDescription.type = NSInMemoryStoreType

    // 2
    let container = NSPersistentContainer(
      name: CoreDataStack.modelName,
      managedObjectModel: CoreDataStack.model)

    // 3
    container.persistentStoreDescriptions = [persistentStoreDescription]

    container.loadPersistentStores { _, error in
      if let error = error as NSError? {
        fatalError("Unresolved error \(error), \(error.userInfo)")
      }
    }

    // 4
    storeContainer = container
  }
}

Here, the code:

  1. Creates an in-memory persistent store.
  2. Creates an NSPersistentContainer instance, passing in the modelName and NSManageObjectModel stored in the CoreDataStack.
  3. Assigns the in-memory persistent store to the container.
  4. Overrides the storeContainer in CoreDataStack.

Nice! With this class in place, you have the baseline for creating tests for you Core Data Model

Different Stores

Above you used an in-memory store, but you may wonder what other options you have available. There are four persistent stores available in Core Data:

  • NSSQLiteStoreType: The most common store used for Core Data is backed by a SQLite database. Xcode’s Core Data Template uses this by default, and it’s also the store used in the project.
  • NSXMLStoreType: Backed by an XML file.
  • NSBinaryStoreType: Backed by a binary data file.
  • NSInMemoryStoreType: This store type saves data to memory so it isn’t persisted. This is useful for unit testing because the data disappears if the app terminates.

core data types

Note: If you’d like to learn more about the different stores in Core Data, check out the Apple Documentation on Persistent Store Types.

With this in place, it’s time to write your first test.

Writing Your First Test

In PandemicReport, ReportService is a small class for handling the CRUD logic. CRUD is an acronym for Create-Read-Update-Delete, the most common features of a persistent store. You’ll write unit tests to verify this functionality is working.

To write tests, you’ll use the XCTest framework in Xcode and subclass XCTestCase.

Start by creating a new Unit Test Case Class under PandemicReportTests and name it ReportServiceTests.swift.

Adding ReportServiceTests

Then, inside ReportServiceTests.swift, add the following code under the import XCTest:

@testable import PandemicReport
import CoreData

This code imports the app and the CoreData framework into your test case.

Next, add the following two properties to the top of the ReportServiceTests:

var reportService: ReportService!
var coreDataStack: CoreDataStack!

These properties hold a reference to the ReportService and CoreDataStack you’re testing.

Back in ReportServiceTests.swift, delete the following:

  1. setUpWithError()
  2. tearDownWithError()
  3. testExample()
  4. testPerformanceExample()

You’ll see why you don’t need them next.

The Set Up and Tear Down

Your unit tests should be isolated and repeatable. XCTestCase has two methods, setUp() and tearDown(), for setting up your test case before each run and cleaning up any test data afterwards. Since each test gets to start with a clean slate, these methods help make your tests isolated and repeatable.

Add the following code below the properties you declared:

override func setUp() {
  super.setUp()
  coreDataStack = TestCoreDataStack()
  reportService = ReportService(
    managedObjectContext: coreDataStack.mainContext,
    coreDataStack: coreDataStack)
}

Here you initialize TestCoreDataStack, which you implemented earlier, as well as ReportService. As previously stated, TestCoreDataStack uses an in-memory store and is initialized each time setUp() executes. Thus, any PandemicReport‘s created aren’t persisted from test run to test run.

On the other hand,tearDown() resets the data after each test run.

Back in ReportServiceTests, add the following:

override func tearDown() {
  super.tearDown()
  reportService = nil
  coreDataStack = nil
}

This code sets the properties to nil in preparation for the next test.

With set up and tear down in place, you can now focus on testing the CRUD of reports.

Adding a Report

Now, you’ll test the app’s existing functionality by writing a simple test to verify the add(_:numberTested:numberPositive:numberNegative:) functionality of the ReportService.

Still in ReportServiceTests, create a new method:

func testAddReport() {
  // 1
  let report = reportService.add(
    "Death Star", 
    numberTested: 1000,
    numberPositive: 999, 
    numberNegative: 1)

  // 2
  XCTAssertNotNil(report, "Report should not be nil")
  XCTAssertTrue(report.location == "Death Star")
  XCTAssertTrue(report.numberTested == 1000)
  XCTAssertTrue(report.numberPositive == 999)
  XCTAssertTrue(report.numberNegative == 1)
  XCTAssertNotNil(report.id, "id should not be nil")
  XCTAssertNotNil(report.dateReported, "dateReported should not be nil")
}

This test verifies that add(_:numberTested:numberPositive:numberNegative:) creates a PandemicReport with the specified values.

The code you added:

  1. Creates a PandemicReport.
  2. Asserts the input values match the created PandemicReport.

To run this test, click Product > Test or press Command+U as a shortcut. Alternatively, you can open the Test navigator, then select PandemicReportsTest and click play.

How to Run the Unit Test

The project builds and runs the test. You’ll see a green checkmark.

First Test Passed

Congratulations! You’ve written your first test.

Next, you’ll learn how to test asynchronous code.

Testing Asynchronous Code

Saving data is Core Data’s most important task. While your test is great, it doesn’t test if the data saves to the persistent store. It runs straight through because the app uses a separate queue for persisting data in the background.

Saving data on the main thread can block the UI, making it unresponsive. But, testing asynchronous code is a little more complicated. Specifically, XCTAssert can’t test if your data saves since you don’t know when the background task finishes.

You’ll solve this by executing the work on the thread associated with the current context by wrapping the add(_:numberTested:numberPositive:numberNegative:) call in a perform(_:). Then, you need to pair the perform(_:) with an expectation to notify the test when the save completes.

Here’s what that’ll look like.

Testing Save

Still inside ReportServiceTests, add:

func testRootContextIsSavedAfterAddingReport() {
  // 1
  let derivedContext = coreDataStack.newDerivedContext()
  reportService = ReportService(
    managedObjectContext: derivedContext,
    coreDataStack: coreDataStack)
    
  // 2
  expectation(
    forNotification: .NSManagedObjectContextDidSave,
    object: coreDataStack.mainContext) { _ in
      return true
  }

  // 3
  derivedContext.perform {
    let report = self.reportService.add(
      "Death Star 2", 
      numberTested: 600,
      numberPositive: 599, 
      numberNegative: 1)

    XCTAssertNotNil(report)
  }

  // 4
  waitForExpectations(timeout: 2.0) { error in
    XCTAssertNil(error, "Save did not occur")
  }
}

Here’s what this does:

  1. Creates a background context and a new instance of ReportService which uses that context.
  2. Creates an expectation that sends a signal to the test case when the Core Data stack sends an NSManagedObjectContextDidSave notification event.
  3. It adds a new report inside a perform(_:) block.
  4. The test waits for the signal that the report saved. The test fails if it waits longer than two seconds.

Expectations are a powerful tool when testing asynchronous code because they let you pause your code and wait for an asynchronous task to complete.

Note: To learn more about testing asynchronous operations with expectations, check out Apple’s Documentation on the topic.

Now, run the test and see a green checkmark next to it.

Testing Core Data Saves

Writing tests helps you find bugs and provide documentation on how a function behaves. But what if you wrote a broken test and it always passed? It’s time to do some TDD!

Test Driven Development (TDD)

Test Driven Development, or TDD, is a development process where you write tests before the production code. By writing your tests first, you ensure your code is testable and developed to meet all the requirements.

You start by writing a minimal amount of code to make the test pass. Then you incrementally make small changes to your functionality and repeat.

One benefit of TDD is your tests act as documentation for how your app works. As your feature set expands over time, your tests do as well and, by extension, your documentation.

As a result, unit tests are a great way to understand how a particular part of an app works. They’re useful if you need a refresher or take over a code base from another developer.

Other benefits of TDD include:

  • Code Coverage: Because you’re writing your tests before your production code, the chance of untested code is slim.
  • Confidence in refactoring: Due to that wide code coverage, and the project being broken into smaller testable units, it’s easier to make major refactors to your code base.
  • Focused: You’re writing the least amount of code to make the test pass, therefore your code base is tidy with less redundancy.

unit testing

The Red, Green, Refactor Cycle

A good unit test is failable, repeatable, quick to run and easy to maintain. By using TDD, you ensure your tests are worthwhile.

Developers often describe the TDD flow as the red-green-refactor cycle:

  1. Red: Write a test that fails first.
  2. Green: Write as little code as possible to make it pass.
  3. Refactor: Revise and optimize the code.
  4. Repeat: Repeat these steps until you feel the code works correctly.

Red, refactor, green, repeat

Note: If you’d like more information on TDD in iOS, check out our tutorial Test Driven Development Tutorial for iOS: Getting Started.

With a little theory on TDD in place, it’s now time you put TDD into practice.

Fetching Reports

To get familiar with TDD, you’ll write a test that validates getReports(). At first, the test will fail, but then you’ll work on making sure it doesn’t.

In ReportServiceTests.swift, add:

func testGetReports() {
  //1
  let newReport = reportService.add(
    "Endor", 
    numberTested: 30,
    numberPositive: 20, 
    numberNegative: 10)
    
  //2
  let getReports = reportService.getReports()

  //3
  XCTAssertNil(getReports)

  //4
  XCTAssertEqual(getReports?.isEmpty, true)

  //5
  XCTAssertTrue(newReport.id != getReports?.first?.id)
}

This code:

  1. Adds a new report and assigns it to newReport.
  2. Gets all reports currently stored in Core Data and assigns them to getReports.
  3. Verifies the result of getReports is nil. This is a failing test. getReports() should return the report added.
  4. Asserts the results array is empty.
  5. Asserts newReport.id isn’t equal to the first object in getReports.

Run the unit test. You’ll see the test fails.

Get Reports test fails

Look at the results of the assert expressions in the failed test:
Failed Asserts Get Reports

This test fails because report service returns the report you added. The asserts are the opposite conditions to what gets returned from getReports. If this test did pass, then getReports() isn’t working, or there’s a bug in the unit test.

Connection errors

To make the test go from red to green, replace the asserts with:

XCTAssertNotNil(getReports)

XCTAssertTrue(getReports?.count == 1)

XCTAssertTrue(newReport.id == getReports?.first?.id)

This code:

  1. Checks that getReports isn’t nil.
  2. Verifies the number of reports is 1.
  3. Asserts the id of the created report and the first result in the reports array are matching.

Next, rerun the unit tests. You’ll see a green checkmark.

Get Reports Passed

Success! You’ve turned your failing test green, confirming the code and test are valid and not a false-positive.

failed test passes

On to the next one!

Updating a Report

Now, write a test that validates update(_:) behaves as expected. Add the following test method:

func testUpdateReport() {
  //1
  let newReport = reportService.add(
    "Snow Planet", 
    numberTested: 0,
    numberPositive: 0, 
    numberNegative: 0)

  //2
  newReport.numberTested = 30
  newReport.numberPositive = 10
  newReport.numberNegative = 20
  newReport.location = "Hoth"

  //3
  let updatedReport = reportService.update(newReport)

  //4
  XCTAssertFalse(newReport.id == updatedReport.id)

  //5
  XCTAssertFalse(updatedReport.numberTested == 30)
  XCTAssertFalse(updatedReport.numberPositive == 10)
  XCTAssertFalse(updatedReport.numberNegative == 20)
  XCTAssertFalse(updatedReport.location == "Hoth")
}

This code:

  1. Creates a new report and assigns it to newReport.
  2. Changes the current properties of newReport to new values.
  3. Calls update: to save the changes made and assigns the updated value to updatedReport.
  4. Asserts that the newReport.id doesn’t match the updatedReport.id.
  5. Ensures the updatedReport properties don’t equal the value assigned to newReport.

Run the unit tests. You’ll see the test failed.

Updated Reports Test Fail

Look at the unit test and notice the five asserts failed.

Updated Reports Failed Assert

They failed because the newReport properties equal the properties of updatedReport. In other words, you’d expect the test to fail here.

Update the asserts in testUpdateReport() to:

XCTAssertTrue(newReport.id == updatedReport.id)
XCTAssertTrue(updatedReport.numberTested == 30)
XCTAssertTrue(updatedReport.numberPositive == 10)
XCTAssertTrue(updatedReport.numberNegative == 20)
XCTAssertTrue(updatedReport.location == "Hoth")

The updated code tests if the newReport.id is equal to updatedReport.id and if the updatedReportproperties are equal to those assigned to newReport.

Rerun the tests and see they now pass.

Update Reports Pass

What you’ve done so far is infinitely better than having no tests at all, but you’re still not truly following the TDD practice. To do so, you must write a test before writing the actual functionality in the app.

Extending Functionality

So far, you’ve added tests to support the existing functionality of the app. Now you’ll bring your TDD skills to the next level by extending the functionality of the service.

To do so, you’ll add the ability to delete a record. Since you’re going to follow true TDD practice this time, you must write the test before the production code.

Build and run and add a report. Once you add a report, you can delete it by swiping on the cell and tapping Delete.

Swipe to delete

But you only deleted the report from the reports instance in ViewController.swift. The report still exists in the Core Data Store and will return when you build and run again.

To check this out, build and run again.

Report Instance still persists.

The report still exists because ReportService.swift doesn’t have a delete function. You’ll add this next.

But first, you must write a test has all the functionality you’d expect from the delete function.

In ReportServiceTests.swift, add:

func testDeleteReport() {
    //1
    let newReport = reportService.add(
      "Starkiller Base", 
      numberTested: 100,
      numberPositive: 80, 
      numberNegative: 20)

    //2
    var fetchReports = reportService.getReports()
    XCTAssertTrue(fetchReports?.count == 1)
    XCTAssertTrue(newReport.id == fetchReports?.first?.id)

    //3
    reportService.delete(newReport)

    //4    
    fetchReports = reportService.getReports()

    //5
    XCTAssertTrue(fetchReports?.isEmpty ?? false)
  }

This code:

  1. Adds a new report.
  2. Gets all the reports from the store which contain the report.
  3. Calls delete on the reportService to delete the report. This will fail as this method doesn’t yet exist.
  4. Gets all the reports from the store again.
  5. Asserts the reports array is empty.

After adding this code, you’ll see a compile error. Currently, ReportService.swift doesn’t implement delete(_:). You’ll add that next.

Compilation error - ReportService has no member delete

Deleting a Report

Now, open ReportService.swift and add the following code at the end of the class:

public func delete(_ report: PandemicReport) {
  // TODO: Delete record from CoreData
}

Here you added an empty declaration. Remember, one of the TDD rules is to write just enough code to make the test pass.

You resolved the compilation error. Rerun the tests and you’ll see one failed test.

Delete Test Failed

The test now failed because the record isn’t deleted from the store. Currently, the delete method in ReportService.swift has an empty body so nothing is actually getting deleted. You’ll fix that next.

Back in ReportService.swift add the following implementation to delete(_:):

//1
managedObjectContext.delete(report)
//2
coreDataStack.saveContext(managedObjectContext)

The code:

  1. Removes the report from the persistent store.
  2. Saves the changes in the current context.

With that added, rerun the unit tests. You’ll see the green checkmark.

Delete Report Test Passed

Good job! You added the delete functionality to the app using the TDD cycle.

devices_iphone-throwing-tests

To finish, go to ViewController.swift and replace tableView(_:commit:forRowAt:) with the following:

func tableView(
  _ tableView: UITableView,
  commit editingStyle: UITableViewCell.EditingStyle,
  forRowAt indexPath: IndexPath
) {
  guard 
    let report = reports?[indexPath.row], 
    editingStyle == .delete 
    else {
      return
  }
  reports?.remove(at: indexPath.row)
  
  //1
  reportService.delete(report)
  
  tableView.deleteRows(at: [indexPath], with: .automatic)
}

Here, you’re calling delete(_:) after removing it from the reports array. Now if you swipe and delete, the report is deleted from the SQLite backed database.

Build and run to check it out.

finished app

And that’s a wrap! Good job introducing testing to a project with Core Data.

Where to Go From Here?

You can download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

In this tutorial, you learned how to:

  • Write a testable Core Data stack using an in-memory store.
  • Write a test for existing and new functionality.
  • Test asynchronous code.
  • Apply the Test Driven Development (TDD) methodology.

For a challenge, try writing a test, or a couple of tests, to prevent adding non-negative numbers for numberTested, numberPositive and numberNegative.

If you enjoyed this tutorial, check out iOS Test-Driven Development by Tutorials. You’ll take a deep dive into how to write maintainable and sustainable apps by writing your tests first or adding tests to already-written apps. You should also check out Test Driven Development Tutorial for iOS: Getting Started.

If you’d like to learn more about Core Data, check out Core Data by Tutorials. In this book, you’ll master Core Data in iOS using Swift. Or check out the Getting Started with Core Data Tutorial.

If you have any questions or comments, join the forum discussion below.