Chapters

Hide chapters

Core Data by Tutorials

Eighth Edition · iOS 14 · Swift 5.3 · Xcode 12

Core Data by Tutorials

Section 1: 11 chapters
Show chapters Hide chapters

7. Unit Testing
Written by Aaron Douglas

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Unit testing is the process of breaking down a project into small, testable pieces, or units, of software. Rather than test that “the app creates a new record when you tap the button” scenario, you might break this down into testing smaller actions, such as the button touch-up event, creating the entity, and testing whether the save succeeded.

In this chapter, you’ll learn how to use the XCTest framework in Xcode to test your Core Data apps. Unit testing Core Data apps isn’t as straightforward as it could be, because most of the tests will depend on a valid Core Data stack. You might not want a mess of test data from the unit tests to interfere with your own manual testing done in the simulator or on a device, so you’ll learn how to keep the test data separate.

Why should you care about unit testing your apps? There are lots of reasons:

  • You can shake out the architecture and behavior of your app at a very early stage. You can test much of the app’s functionality without needing to worry about the UI.
  • You gain the confidence to add features or refactor your project without worrying about breaking things. If you have existing tests that pass, you can be confident the tests will fail if you break something later, so you’ll know about the problem immediately.
  • You can keep your team of multiple developers from falling over each other as each developer can make and test their changes independently of others.
  • You can save time in testing. Instead of tapping through three different screens and entering test data in the fields, you can run a small test for any part of your app’s code instead of manually working through the UI.

You’ll get a good introduction to XCTest in this chapter, but you should have a basic understanding of it already to get the most from this chapter.

For more information, check out Apple’s documentation (https://developer.apple.com/documentation/xctest), our iOS Unit Testing and UI Testing Tutorial (https://www.raywenderlich.com/960290-ios-unit-testing-and-ui-testing-tutorial), or our book iOS Test-Driven Development by Tutorials, which digs deep on unit testing and writing testable code.

Getting started

The sample project you’ll work with in this chapter, CampgroundManager, is a reservation system to track campground sites, the amenities for each site and the campers themselves.

The app is a work in progress. The basic concept: a small campground could use this app to manage their campsites and reservations, including the schedule and payments. The user interface is extremely basic; it’s functional but doesn’t provide much value. That’s OK — in this tutorial, you’re never going to build and run the app!

The business logic for the app has been broken down into small pieces. You’re going to write unit tests to help with the design. As you develop the unit tests and flesh out the business logic, it’ll be easy to see what work remains for the user interface.

The business logic is split into three distinct classes arranged by subject. There’s one for campsites, one for campers and one for reservations. All classes have the suffix Service, and your tests will focus on these service classes.

Access control

By default, classes in Swift have the “internal” access level. That means you can only access them from within their own modules. Since the app and the tests are in separate targets and separate modules, you normally wouldn’t be able to access the classes from the app in your tests.

Core Data stack for testing

Since you’ll be testing the Core Data parts of the app, the first order of business is getting the Core Data stack set up for testing.

import CampgroundManager
import Foundation
import CoreData

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

    let container = NSPersistentContainer(
      name: CoreDataStack.modelName,
      managedObjectModel: CoreDataStack.model)
    container.persistentStoreDescriptions[0].url =
      URL(fileURLWithPath: "/dev/null")

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

    self.storeContainer = container
  }
}

Your first test

Unit tests work best when you design your app as a collection of small modules. Instead of throwing all of your business logic into one huge view controller, you create a class (or classes) to encapsulate that logic.

import CampgroundManager
import CoreData
// MARK: Properties
var camperService: CamperService!
var coreDataStack: CoreDataStack!
override func setUp() {
  super.setUp()

  coreDataStack = TestCoreDataStack()
  camperService = CamperService(
    managedObjectContext: coreDataStack.mainContext,
    coreDataStack: coreDataStack)
}
override func tearDown() {
  super.tearDown()

  camperService = nil
  coreDataStack = nil
}
func testAddCamper() {
  let camper = camperService.addCamper(
    "Bacon Lover",
    phoneNumber: "910-543-9000")

  XCTAssertNotNil(camper, "Camper should not be nil")
  XCTAssertTrue(camper?.fullName == "Bacon Lover")
  XCTAssertTrue(camper?.phoneNumber == "910-543-9000")
}

Asynchronous tests

When using a single managed object context in Core Data, everything runs on the main UI thread. However, it’s a common pattern to create background contexts, which are children of the main context, for doing work without blocking the UI.

let expectation = expectation(withDescription: "Done!")

someService.callMethodWithCompletionHandler() {
  expectation.fulfill()
}

waitForExpectations(timeout: 2.0, handler: nil)
func testRootContextIsSavedAfterAddingCamper() {
  //1
  let derivedContext = coreDataStack.newDerivedContext()
  camperService = CamperService(
    managedObjectContext: derivedContext,
    coreDataStack: coreDataStack)

  //2
  expectation(
    forNotification: .NSManagedObjectContextDidSave,
    object: coreDataStack.mainContext) { _ in
      return true
  }

  //3
  derivedContext.perform {
    let camper = self.camperService.addCamper(
      "Bacon Lover",
      phoneNumber: "910-543-9000")
    XCTAssertNotNil(camper)
  }

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

Tests first

An important function of CampgroundManager is its ability to reserve sites for campers. Before it can accept reservations, the system has to know about all of the campsites at the campground. CampSiteService was created to help with adding, deleting and finding campsites.

import UIKit
import XCTest
import CampgroundManager
import CoreData

class CampSiteServiceTests: XCTestCase {

  // MARK: Properties
  var campSiteService: CampSiteService!
  var coreDataStack: CoreDataStack!

  override func setUp() {
    super.setUp()

    coreDataStack = TestCoreDataStack()
    campSiteService = CampSiteService(
      managedObjectContext: coreDataStack.mainContext,
      coreDataStack: coreDataStack)
  }

  override func tearDown() {
    super.tearDown()

    campSiteService = nil
    coreDataStack = nil
  }
}
func testAddCampSite() {
  let campSite = campSiteService.addCampSite(
    1,
    electricity: true,
    water: true)

  XCTAssertTrue(
    campSite.siteNumber == 1,
    "Site number should be 1")
  XCTAssertTrue(
    campSite.electricity!.boolValue,
    "Site should have electricity")
  XCTAssertTrue(
    campSite.water!.boolValue,
    "Site should have water")
}
func testRootContextIsSavedAfterAddingCampsite() {
  let derivedContext = coreDataStack.newDerivedContext()

  campSiteService = CampSiteService(
    managedObjectContext: derivedContext,
    coreDataStack: coreDataStack)

  expectation(
    forNotification: .NSManagedObjectContextDidSave,
    object: coreDataStack.mainContext) { _ in
      return true
  }

  derivedContext.perform {
    let campSite = self.campSiteService.addCampSite(
      1,
      electricity: true,
      water: true)
    XCTAssertNotNil(campSite)
  }

  waitForExpectations(timeout: 2.0) { error in
    XCTAssertNil(error, "Save did not occur")
  }
}
func testGetCampSiteWithMatchingSiteNumber() {
  _ = campSiteService.addCampSite(
    1,
    electricity: true,
    water: true)

  let campSite = campSiteService.getCampSite(1)
  XCTAssertNotNil(campSite, "A campsite should be returned")
}

func testGetCampSiteNoMatchingSiteNumber() {
  _ = campSiteService.addCampSite(
    1,
    electricity: true,
    water: true)

  let campSite = campSiteService.getCampSite(2)
  XCTAssertNil(campSite, "No campsite should be returned")
}

public func getCampSite(_ siteNumber: NSNumber) -> CampSite? {
  let fetchRequest: NSFetchRequest<CampSite> =
    CampSite.fetchRequest()
  fetchRequest.predicate = NSPredicate(
    format: "%K = %@",
    argumentArray: [#keyPath(CampSite.siteNumber), siteNumber])

  let results: [CampSite]?
  do {
    results = try managedObjectContext.fetch(fetchRequest)
  } catch {
    return nil
  }
  return results?.first
}

Validation and refactoring

ReservationService will contain some fairly complex logic to figure out if a camper is able to reserve a site. The unit tests for ReservationService will require every service created so far to test its operation.

import Foundation
import CoreData
import XCTest
import CampgroundManager

class ReservationServiceTests: XCTestCase {

  // MARK: Properties
  var campSiteService: CampSiteService!
  var camperService: CamperService!
  var reservationService: ReservationService!
  var coreDataStack: CoreDataStack!

  override func setUp() {
    super.setUp()
    coreDataStack = TestCoreDataStack()
    camperService = CamperService(
      managedObjectContext: coreDataStack.mainContext,
      coreDataStack: coreDataStack)
    campSiteService = CampSiteService(
      managedObjectContext: coreDataStack.mainContext,
      coreDataStack: coreDataStack)
    reservationService = ReservationService(
      managedObjectContext: coreDataStack.mainContext,
      coreDataStack: coreDataStack)
  }

  override func tearDown() {
    super.tearDown()

    camperService = nil
    campSiteService = nil
    reservationService = nil
    coreDataStack = nil
  }
}
func testReserveCampSitePositiveNumberOfDays() {
  let camper = camperService.addCamper(
    "Johnny Appleseed",
    phoneNumber: "408-555-1234")!
  let campSite = campSiteService.addCampSite(
    15,
    electricity: false,
    water: false)

  let result = reservationService.reserveCampSite(
    campSite,
    camper: camper,
    date: Date(),
    numberOfNights: 5)

  XCTAssertNotNil(
    result.reservation,
    "Reservation should not be nil")
  XCTAssertNil(
    result.error,
    "No error should be present")
  XCTAssertTrue(
    result.reservation?.status == "Reserved",
    "Status should be Reserved")
}
func testReserveCampSiteNegativeNumberOfDays() {
  let camper = camperService.addCamper(
    "Johnny Appleseed",
    phoneNumber: "408-555-1234")!
  let campSite = campSiteService.addCampSite(
    15,
    electricity: false,
    water: false)

  let result = reservationService!.reserveCampSite(
    campSite,
    camper: camper,
    date: Date(),
    numberOfNights: -1)

  XCTAssertNotNil(
    result.reservation,
    "Reservation should not be nil")
  XCTAssertNotNil(
    result.error,
    "An error should be present")
  XCTAssertTrue(
    result.error?.userInfo["Problem"] as? String == "Invalid number of days",
    "Error problem should be present")
  XCTAssertTrue(
    result.reservation?.status == "Invalid",
    "Status should be Invalid")
}
reservation.status = "Reserved"
if numberOfNights <= 0 {
  reservation.status = "Invalid"
  registrationError = NSError(
    domain: "CampingManager",
    code: 5,
    userInfo: ["Problem": "Invalid number of days"])
} else {
  reservation.status = "Reserved"
}

Key points

  • Unit tests should follow the FIRST principles: Fast, Isolated, Repeatable, Self-verifying, and Timely.
  • Create a persistent store specific for unit testing and reset its contents with every test. Using an in-memory SQLite store is the simplest approach.
  • Core Data can be used asynchronously and is easily tested with the XCTestExpectation class.

Where to go from here?

You’ve probably heard many times that unit testing your work is key in maintaining a stable software product. While Core Data can help eliminate a lot of error-prone persistence code from your project, it can be a source of logic errors if used incorrectly.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now