7.
Unit Testing
Written by Aaron Douglas
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/709-ios-unit-testing-and-ui-testing-tutorial), or our book iOS 9 by Tutorials, which includes a chapter on Testing and XCTest.
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.
There are three ways around this issue:
- You can mark classes and methods in your app as
publicto make them visible from the tests (oropento allow subclassing). - You can add classes to the test target in the File Inspector so they will be compiled in, and accessible from, the tests.
- You can add the Swift keyword
@testablein front of any import in your unit test to gain access to everything imported in the class.
In the CampgroundManager sample project, the necessary classes and methods in the app target are already marked as public or open. That means you’ll just need to import CampgroundManager from the tests and you’ll be able to access whatever you need.
Note: Using
@testablewould be the easiest approach, but its existence in the language is somewhat debatable. In theory, only public methods should be unit tested; anything notpublicisn’t testable because there isn’t a public interface or contract. Using@testableis definitely more acceptable than just blindly addingpublicto all of your classes and functions.
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.
Good unit tests follow the acronym FIRST:
-
Fast: If your tests take too long to run, you won’t bother running them.
-
Isolated: Any test should function properly when run on its own or before or after any other test.
-
Repeatable: You should get the same results every time you run the test against the same codebase.
-
Self-verifying: The test itself should report success or failure; you shouldn’t have to check the contents of a file or a console log.
-
Timely: There’s some benefit to writing the tests after you’ve already written the code, particularly if you’re writing a new test to cover a new bug. Ideally, though, the tests come first to act as a specification for the functionality you’re developing.
When unit tests are executed, the application is started and the test run within the environment of the running app. In practice this can cause problems if the state of the app is being affected by tests running and vice versa. CampgroundManager has been set up to allow the unit test execution to override the AppDelegate. This prevents the app from interfering with the unit tests. Check out main.swift and TestingAppDelegate.swift for more details.
CampgroundManager uses Core Data to store data in a database file on disk. That doesn’t sound very Isolated, since the data from one test may be written out to the database and could affect other tests. It doesn’t sound very Repeatable, either, since data will build up in the database file each time you run a test. You could manually delete and recreate the database file before running each test, but that wouldn’t be very Fast.
The solution is a modified Core Data stack that uses an in-memory store instead of an SQLite-backed store. This will be fast and provide a clean slate every time.
The CoreDataStack you’ve been using in most of this book can support multiple contexts, including a background root/parent context to which NSPersistentStoreCoordinator is connected. When you use CoreDataStack for a test, you want it to access the in-memory store instead of the SQLite database.
Create a new class that subclasses CoreDataStack so you can change the store.
- Right-click Services under the CampgroundManagerTests group and click New File.
- Select Swift File under iOS ▸ Source. Click Next.
- Name the file TestCoreDataStack.swift. Make sure only the CampgroundManagerTests target is selected.
- Click Create.
- Select Don’t Create if prompted to add an Objective-C bridging header.
Replace the contents of the file with the following:
import CampgroundManager
import Foundation
import CoreData
class TestCoreDataStack: CoreDataStack {
override init() {
super.init()
let persistentStoreDescription =
NSPersistentStoreDescription()
persistentStoreDescription.type = NSInMemoryStoreType
let container = NSPersistentContainer(
name: CoreDataStack.modelName,
managedObjectModel: CoreDataStack.model)
container.persistentStoreDescriptions =
[persistentStoreDescription]
container.loadPersistentStores { (_, error) in
if let error = error as NSError? {
fatalError(
"Unresolved error \(error), \(error.userInfo)")
}
}
self.storeContainer = container
}
}
This class subclasses CoreDataStack and only overrides the default value of a single property: storeContainer. Since you’re overriding the value in init(), the persistent container from CoreDataStack isn’t used — or even instantiated. The persistent container in TestCoreDataStack uses an in-memory store only. An in-memory store is never persisted to disk, which means you can instantiate the stack and write as much data you want in the test. When the test ends — poof — the in-memory store clears out automatically. With the stack in place, it’s time to create your first test!
Note: There are fundamental differences between how a SQLite store and an in-memory store operate under the covers. You may find your app’s specific use cases elicit quirks/bugs specific to SQLite. If your testing situation requires a SQLite store, create a test persistent store using SQLite and provide a different filename than what is used in production. Your
tearDown()method would then close the test store and delete the file with every test.
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.
In most cases, you’ll probably be adding unit tests to a partially-complete application. In the case of CampgroundManager, the CamperService, CampSiteService and ReservationService classes have already been created, but they aren’t yet feature-complete. You’ll test the simplest class, CamperService, first.
Begin by creating a new test class:
- Right-click the Services group under the CampgroundManagerTests group and click New File.
- Select iOS ▸ Source▸ Unit Test Case Class. Click Next.
- Name the class CamperServiceTests; subclass of XCTestCase should already be selected. Choose Swift for the language, then click Next.
- Make sure the CampgroundManagerTests target checkbox is the only target selected. Click Create.
In CamperServiceTests.swift, import the app and Core Data frameworks into the test case, along with the other existing import statements:
import CampgroundManager
import CoreData
Next, add the following two properties to the class:
// MARK: Properties
var camperService: CamperService!
var coreDataStack: CoreDataStack!
These properties will hold references to the CamperService instance under test, and to the Core Data stack. The properties are implicitly unwrapped optionals, since they’ll be initialized in setUp rather than in init.
Next, replace the implementation of setUp with the following:
override func setUp() {
super.setUp()
coreDataStack = TestCoreDataStack()
camperService = CamperService(
managedObjectContext: coreDataStack.mainContext,
coreDataStack: coreDataStack)
}
setUp is called before each test runs. This is your chance to create any resources required by all unit tests in the class. In this case, you initialize the camperService and coreDataStack properties.
It’s wise to reset your data after every test - your tests are Isolated and Repeatable, remember? Using the in-memory store and creating a new context in setUp accomplishes this reset for you.
Notice that the CoreDataStack instance is actually a TestCoreDataStack instance. The CamperService initialization method takes the context it needs along with an instance of CoreDataStack, since the context save methods are part of that class. You can also use setUp() to insert standard test data into the context for use later.
Next, replace tearDown with the following implementation:
override func tearDown() {
super.tearDown()
camperService = nil
coreDataStack = nil
}
tearDown is the opposite of setUp, and is called after each test executes. Here, the method will simply make all the properties nil, resetting CoreDataStack after every test.
There’s only a single method on CamperService at this point: addCamper(_:phonenumber:). Still in CamperServiceTests.swift, create a new method to test addCamper:
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")
}
You create a camper with certain properties, then check to confirm a camper exists with the properties you expect.
Remove the testExample and testPerformanceExample methods from the class.
It’s a simple test, but it ensures if any logic inside of addCamper is modified, the basic operation doesn’t change. For example, if you add some new data validation logic to prevent bacon-loving people from reserving campgrounds, addCamper might return nil. This test would then fail, alerting you that either you made a mistake in the validation or the test needs to be updated.
Note: To round out this test case in a real development context, you’d want to write unit tests for strange scenarios such as
nilparameters, empty parameters, or duplicate camper namess.
Run the unit tests by clicking on the Product menu, then selecting Test (or type Command+U). You should see a green checkmark in Xcode.
There’s your first test! This type of testing is useful for leveraging your data models and checking the attributes were stored correctly.
Also notice how the test serves as micro-documentation for people using the API. It’s an example of how to call addCamper and describes the expected behavior. In this case, the method should return a valid object and not nil.
Notice that this test creates the object and checks the attributes, but doesn’t save anything to the store. This project uses a separate queue context so it can persist data in the background. However, the test runs straight through; this means you can’t check for the save result with an XCTAssert as you can’t be sure when the background operation has completed. Saving data is an important part of Core Data — so how can you test this part of the app?
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.
Performing work on the correct thread for a given context is easy: You simply wrap the work in performBlockAndWait() or performBlock() to ensure it’s executed on the thread associated with the context. performBlockAndWait() will wait to finish execution of the block before continuing, while performBlock() will immediately return and queue the execution on the context.
Testing performBlock() executions can be tricky since you need some way to send a signal about the test status to the outside world from inside the block. Luckily, there is a feature in XCTestCase called expectations that help with this.
The example below shows how you might use an expectation to wait for an asynchronous method to complete before finishing the test:
let expectation = expectation(withDescription: "Done!")
someService.callMethodWithCompletionHandler() {
expectation.fulfill()
}
waitForExpectations(timeout: 2.0, handler: nil)
The key is that something must fulfill or trigger the expectation so the test moves forward. The wait method at the end takes a time parameter (in seconds) so the test doesn’t wait forever and can time out (and fail) in case the expectation is never fulfilled.
In the example provided, fulfill() is called explicitly in the completion handler passed into the tested method. With Core Data save operations, it’s easier to listen for the NSManagedObjectContextDidSave notification, since it happens in a place where you can’t call fulfill() explicitly.
Add a new method to CamperServiceTests.swift to test that the root context is saved when you add a new camper:
func testRootContextIsSavedAfterAddingCamper() {
//1
let derivedContext = coreDataStack.newDerivedContext()
camperService = CamperService(
managedObjectContext: derivedContext,
coreDataStack: coreDataStack)
//2
expectation(
forNotification: .NSManagedObjectContextDidSave,
object: coreDataStack.mainContext) {
notification 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")
}
}
Here’s a breakdown of the code:
-
For this test, you create a background context to do the work. The
CamperServiceinstance is recreated using this context instead of the main context. -
You create a text expectation linked to a notification. In this case, the expectation is linked to the
NSManagedObjectContextDidSavenotification from the root context of the Core Data stack. The handler for the notification is simple: it returnstruesince all you care about is that the notification is fired. -
You add the camper, exactly the same as before, but this time inside a
performblock on the derived context, since that’s a background context and needs to run operations on its own thread. -
The test waits up to two seconds for the expectation. If there’s errors or the timeout passes, the
errorparameter for the handler block will contain a value.
Run the unit tests, and you should see a green checkmark next to this new method. It’s important to keep UI-blocking operations such as Core Data save actions off the main thread so your app stays responsive.
Test expectations are invaluable to make sure these asynchronous operations are covered by unit tests.
You’ve added tests for existing features in the app; now it’s time to add some features and tests yourself. Or for even more fun — perhaps write the tests first?
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.
Open CampSiteService, and you’ll notice that the only method implemented is addCampSite. There’s no unit tests for this method, so create a test case for the service:
- Right-click Services under the CampgroundManagerTests group and click New File.
- Select iOS\Source\Unit Test Case Class. Click Next.
- Name the class CampSiteServiceTests; subclass of XCTestCase should already be selected. Select Swift for the language, then click Next.
- Make sure the CampgroundManagerTests target checkbox is the only target selected. Click Create.
Replace the contents of the file with the following:
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
}
}
This looks very similar to the previous test class. As your suite of tests expands and you notice common or repeated code, you can refactor your tests as well as your application code. You can feel safe doing this because the unit tests will fail if you mess anything up!
Add the following new method to test adding a campsite. This looks and works like the method for testing the creation of a new camper:
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")
}
To ensure the context is saved during this method, add the following to test:
func testRootContextIsSavedAfterAddingCampsite() {
let derivedContext = coreDataStack.newDerivedContext()
campSiteService = CampSiteService(
managedObjectContext: derivedContext,
coreDataStack: coreDataStack)
expectation(
forNotification: .NSManagedObjectContextDidSave,
object: coreDataStack.mainContext) {
notification 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")
}
}
This method should also look quite familiar to the ones you created earlier. Run the unit tests; everything should pass. At this point, you should be feeling a bit paranoid. What if the tests are broken and they always pass? It’s time to do some test-driven development and get the buzz that comes from turning red tests to green!
Note: Test-Driven Development (TDD) is a way of developing an application by writing a test first, then incrementally implementing the feature until the test passes. The code is then refactored for the next feature or improvement. Covering TDD methodologies is beyond the scope of this chapter, but the steps you’re covering here will help you use TDD if you do decide to follow it.
Add the following methods to CampSiteServiceTests.swift to test getCampSite():
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")
}
Both tests use the addCampSite method to create a new CampSite. You know this method works from your previous test, so there’s no need to test it again. The actual tests cover retrieving the CampSite by ID and testing whether the result is nil.
Think about how more reliable it is to start every test with an empty database. If you weren’t using the in-memory store, there could easily be a campsite matching the ID for the second test, which would then fail!
Run the unit tests. The test expecting a CampSite fails because you haven’t implemented getCampSite yet.
The other unit test — the one that expects no site — passes. This is an example of a false positive, because the method always returns nil. It’s important that you add tests for multiple scenarios for each method to exercise as many code paths as possible.
Implement getCampSite in CampSiteService.swift with the following code:
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
}
Now rerun the unit tests and you should see green check marks. Ah, the sweet satisfaction of success!
Note: The final project for this chapter included in the resources bundled with this book includes unit tests covering multiple scenarios for each method. You can browse through that code for even more examples.
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.
Create a new test class as you’ve done before:
-
Right-click Services under the CampgroundManagerTests group and click New File.
-
Select iOS ▸ Source ▸ Unit Test Case Class. Click Next.
-
Name the class ReservationServiceTests; subclass of XCTestCase should already be selected. Select Swift for the language. Click Next.
-
Make sure the CampgroundManagerTests target checkbox is the only target selected. Click Create.
Replace the contents of the file with the following:
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
}
}
This is a slightly longer version of the set up and tear down code you’ve used in the previous test case classes. Along with setting up the Core Data stack as usual, you’re creating a fresh instance of each service in setUp for each test.
Add the following method to test creating a reservation:
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")
}
The unit test creates a camper and campsite, both required to reserve a site. The new part here is you’re using the reservation service to reserve the campsite, linking the camper and campsite together with a date.
The unit test verifies that a Reservation object was created and an NSError object wasn’t in the returned tuple. Looking at the reserveCampSite call, you’ve probably realized the number of nights should be at least greater than zero. Add the following unit test to test that condition:
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")
}
Run the unit test, and you’ll notice that the test fails. Apparently whoever wrote ReservationService didn’t think to check for this! It’s a good thing you caught that bug here in a test before it made it out into the world — maybe booking a negative number of nights would cascade down to issuing a refund!
Tests are great places for probing your system and finding holes in its behavior. The test also serves as a quasi-specification; the tests indicate you’re still expecting a valid, non-nil result, but one with the error condition set.
Open ReservationService.swift and add the check for numberOfNights to reserveCampSite. Replace the line reservation.status = "Reserved" with the following:
if numberOfNights <= 0 {
reservation.status = "Invalid"
registrationError = NSError(domain: "CampingManager",
code: 5,
userInfo: ["Problem": "Invalid number of days"])
} else {
reservation.status = "Reserved"
}
Finally, change registrationError from a constant to a variable by replacing let with var.
Now rerun the tests and check that the negative number of days test passes. You can see how the process continues with refactoring when you want to add additional functionality or validation rules.
Whether you know the details of the code you’re testing or you’re treating it like a black box, you can write these kinds of tests against the API to see if it behaves as you expect. If it does, great! That means the test will ensure your code functions as expected. If not, you either need to change your test to match the code, or change the code to match the test.
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 store is the simplest approach.
- Core Data can be used asynchronously and is easily tested with the
XCTestExpectationclass.
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.
Writing unit tests that can use Core Data will help stabilize your code before it even reaches your users. XCTestExpectation is a simple, yet powerful tool in your quest to test Core Data in an asynchronous manner. Use it wisely!
As a challenge, CampSiteService has a number of methods that are not implemented yet, marked with TODO comments. Using a TDD approach, write unit tests and then implement the methods to make the tests pass. If you get stuck, check out the challenge project included in the resources for this chapter for a sample solution.