Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

4. Test Expressions
Written by Michael Katz

Heads up... You’re accessing parts of this content for free, with some sections shown as mhmumpvaw text.

Heads up... You’re accessing parts of this content for free, with some sections shown as djdemsnef text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

The TDD process is straightforward, but writing good tests may not always be. Fortunately, each year, Xcode and Swift have become more capable. This means you have many features at your disposal that help with both writing and running tests.

This chapter covers how to use the XCTAssert functions. These are the primary actors of the test infrastructure. You’ll go through gathering code coverage to verify the minimum amount of testing. Finally, you’ll use the test debugger to find and fix test errors.

In this chapter, you’ll learn about:

  • XCTAssert functions
  • UIViewController testing
  • Code Coverage
  • Test debugging

Note: Be sure to use the Chapter 4 starter project rather than continuing with the Chapter 3 final project. It has a few new things added to it, including placeholders for the code to add in this tutorial.

Assert methods

In Chapter 3, “TDD App Setup,” you used XCTAssertEqual exclusively. There are several other assert functions in XCTest:

  • Equality: XCTAssertEqual, XCTAssertNotEqual
  • Truthiness: XCTAssertTrue, XCTAssertFalse
  • Nullability: XCTAssertNil, XCTAssertNotNil
  • Comparison: XCTAssertLessThan, XCTAssertGreaterThan, XCTAssertLessThanOrEqual, XCTAssertGreaterThanOrEqual
  • Erroring: XCTAssertThrowsError, XCTAssertNoThrow

Ultimately, any test case can be boiled down to a conditional: (does it meet an expectation or not) so any test assert can be re-composed into a XCTAssertTrue.

Note: With XCTest, a test is marked as passed as long as there are no failures. This means that it does not require a positive XCTAssert assertion. A test with no asserts will be marked as success, even though it does not test anything!

App state

In the previous chapter, you built out the functionality to move the app from a not started state to an in-progress one. Now is a good time to think about about the whole app lifecycle.

Heads up... You’re accessing parts of this content for free, with some sections shown as prrunqwot text.

Heads up... You’re accessing parts of this content for free, with some sections shown as nztemjhen text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
Woondl Yobjfilag Nog Mxaryup Ow Swaxyeqw Viufok klaxl() yaera() yaveqo() karsakr() Kqufc

Asserting true and false

To build out the state transitions, you need to add some more information to the app about the user. The completed and caught states depend on the user activity, the set goal and Nessie’s activity. To keep the architecture clean, the app state information will be kept separate from the raw data that is tracking the user.

@testable import FitNess
var sut: DataModel!
class DataModel {

}

Heads up... You’re accessing parts of this content for free, with some sections shown as qqduhhtub text.

Heads up... You’re accessing parts of this content for free, with some sections shown as kklykvmej text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
override func setUpWithError() throws {
  try super.setUpWithError()
  sut = DataModel()
}

override func tearDownWithError() throws {
  sut = nil
  try super.tearDownWithError()
}
// MARK: - Goal
func testModel_whenStarted_goalIsNotReached() {
  XCTAssertFalse(
    sut.goalReached,
    "goalReached should be false when the model is created")
}
var goalReached: Bool { return false }
func testModel_whenStepsReachGoal_goalIsReached() {
  // given
  sut.goal = 1000

  // when
  sut.steps = 1000

  // then
  XCTAssertTrue(sut.goalReached)
}
var goal: Int?
var steps: Int = 0

Heads up... You’re accessing parts of this content for free, with some sections shown as nnpagnlah text.

Heads up... You’re accessing parts of this content for free, with some sections shown as zddehwluz text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
var goalReached: Bool {
  if let goal = goal,
    steps >= goal {
      return true
  }
  return false
}

Testing Errors

If the optional goal property isn’t set, it doesn’t make sense for the app to enter the inProgress state. Therefore starting the app without a goal is an error!

func start() throws {
@IBAction func startStopPause(_ sender: Any?) {
  do {
    try AppModel.instance.start()
  } catch {
    showNeedGoalAlert()
  }

  updateUI()
}
func testModelWithNoGoal_whenStarted_throwsError() {
  XCTAssertThrowsError(try sut.start())
}

Heads up... You’re accessing parts of this content for free, with some sections shown as qmzuzznyb text.

Heads up... You’re accessing parts of this content for free, with some sections shown as ccquzzsuj text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
let dataModel = DataModel()
guard dataModel.goal != nil else {
  throw AppError.goalNotSet
}
func givenGoalSet() {
  sut.dataModel.goal = 1000
}
func testStart_withGoalSet_doesNotThrow() {
  // given
  givenGoalSet()

  // then
  XCTAssertNoThrow(try sut.start())
}
// given
givenGoalSet()

Heads up... You’re accessing parts of this content for free, with some sections shown as pjfejhxas text.

Heads up... You’re accessing parts of this content for free, with some sections shown as gvlelkreg text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
func givenGoalSet() {
  AppModel.instance.dataModel.goal = 1000
}
// given
givenGoalSet()

View controller testing

Now that the model can have a goal set and an app state that checks it, the next feature is to expose that the state to the user. In the previous chapter, you wrote some unit tests for StepCountController. Now build on that with some proper view controller unit testing.

Functional view controller testing

The important thing when testing view controllers is to not test the views and controls directly. This is better done using UI automation tests. Here, the goal is to check the logic and state of the view controller.

func testDataModel_whenGoalUpdate_updatesToNewGoal() {
  // when
  sut.updateGoal(newGoal: 50)

  // then
  XCTAssertEqual(AppModel.instance.dataModel.goal, 50)
}
AppModel.instance.dataModel.goal = nil

Heads up... You’re accessing parts of this content for free, with some sections shown as gcfidfrup text.

Heads up... You’re accessing parts of this content for free, with some sections shown as kghovqxef text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
func updateGoal(newGoal: Int) {
  AppModel.instance.dataModel.goal = newGoal
}

Using the host app

The next requirement for the app is that the central view should show the user’s avatar in the running position. The word should signifies an assertion, so you’ll write one, now. First, open StepCountControllerTests.swift. Next, add the following under // MARK: - Chase View:

func testChaseView_whenLoaded_isNotStarted() {
  // when loaded, then
  let chaseView = sut.chaseView
  XCTAssertEqual(chaseView?.state, .notStarted)
}

Heads up... You’re accessing parts of this content for free, with some sections shown as dgkusqqof text.

Heads up... You’re accessing parts of this content for free, with some sections shown as ffzaddxok text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
import UIKit
@testable import FitNess

func getRootViewController() -> RootViewController {
  guard let controller =
    (UIApplication.shared.connectedScenes.first as? UIWindowScene)?
    .windows
    .first?
    .rootViewController as? RootViewController else {
    assert(false, "Did not a get RootViewController")
  }
  return controller
}
import UIKit
@testable import FitNess

extension RootViewController {
  var stepController: StepCountController {
    return children.first { $0 is StepCountController }
      as! StepCountController
  }
}

Fixing the tests

Go back to StepCountControllerTests.swift, and replace setUpWithError() with the following:

override func setUpWithError() throws {
  try super.setUpWithError()
  let rootController = getRootViewController()
  sut = rootController.stepController
}
func givenInProgress() {
  givenGoalSet()
  sut.startStopPause(nil)
}
func testChaseView_whenInProgress_viewIsInProgress() {
  // given
  givenInProgress()

  // then
  let chaseView = sut.chaseView
  XCTAssertEqual(chaseView?.state, .inProgress)
}

Heads up... You’re accessing parts of this content for free, with some sections shown as glkycdcik text.

Heads up... You’re accessing parts of this content for free, with some sections shown as vxlegsnit text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
private func updateChaseView() {
  chaseView.state = AppModel.instance.appState
}

Test ordering matters

Build and test the whole target, and most of the tests should pass, but not testController_whenCreated_buttonLabelIsStart. This test fails.

Heads up... You’re accessing parts of this content for free, with some sections shown as rzkehjhyj text.

Heads up... You’re accessing parts of this content for free, with some sections shown as zrmopqmax text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

func givenInProgress() {
  givenGoalSet()
  try! sut.start()
}
// MARK: - Restart
func testAppModel_whenReset_isInNotStartedState() {
  // given
  givenInProgress()

  // when
  sut.restart()

  // then
  XCTAssertEqual(sut.appState, .notStarted)
}
func restart() {
  appState = .notStarted
}

Heads up... You’re accessing parts of this content for free, with some sections shown as sqlozssed text.

Heads up... You’re accessing parts of this content for free, with some sections shown as cnlimqzev text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
override func tearDownWithError() throws {
  AppModel.instance.dataModel.goal = nil
  AppModel.instance.restart()
  sut.updateUI()
  try super.tearDownWithError()
}

Randomized order

There is also an option in the Test action of the scheme to randomize the test order. Edit the FitNess scheme. Select the Test action. In the center pane, next to FitNessTests is an Options… button. Click that and, in the pop-up, check Randomize execution order. This will cause the tests to run in a random order each time.

Code coverage

While on the subject of the scheme editor, open up the Test Action again. This time select the Options tab. There is a checkbox for Code Coverage. Check it.

Heads up... You’re accessing parts of this content for free, with some sections shown as blqaqkxax text.

Heads up... You’re accessing parts of this content for free, with some sections shown as zxwacjvev text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Debugging tests

When it comes to debugging tests, you’ve already practiced the first line of defense. That is: “Am I testing the right thing?”

Heads up... You’re accessing parts of this content for free, with some sections shown as cgqumrlec text.

Heads up... You’re accessing parts of this content for free, with some sections shown as bfjuvhzed text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Using test breakpoints

With Nessie in the picture, the data model gets a little more complicated. Here are the new rules with Nessie:

// MARK: - Nessie
func testModel_whenStarted_userIsNotCaught() {
  XCTAssertFalse(sut.caught)
}
// MARK: - Nessie

let nessie = Nessie()
var distance: Double = 0

var caught: Bool {
  return nessie.distance >= distance
}

Heads up... You’re accessing parts of this content for free, with some sections shown as dgbihwvum text.

Heads up... You’re accessing parts of this content for free, with some sections shown as ktwezgvuq text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

var caught: Bool {
  return distance > 0 && nessie.distance >= distance
}

Completing coverage

If you take a look at the code coverage for DataModel.swift, it is no longer 100%. If you look at the file, notice the striped annotation in the updated caught. Hovering over the stripe shows that not of all the conditions were checked. The 0 tells you there is more test.

func testModel_whenUserAheadOfNessie_isNotCaught() {
  // given
  sut.distance = 1000
  sut.nessie.distance = 100

  // then
  XCTAssertFalse(sut.caught)
}

func testModel_whenNessieAheadofUser_isCaught() {
  // given
  sut.nessie.distance = 1000
  sut.distance = 100

  // then
  XCTAssertTrue(sut.caught)
}

Finishing out the requirements

There is one final piece that hasn’t been accounted for yet: The user cannot reach the goal if they have been caught. Add this test to the Goal tests section:

func testGoal_whenUserCaught_cannotBeReached() {
  //given goal should be reached
  sut.goal = 1000
  sut.steps = 1000

  // when caught by nessie
  sut.distance = 100
  sut.nessie.distance = 100

  // then
  XCTAssertFalse(sut.goalReached)
}

Heads up... You’re accessing parts of this content for free, with some sections shown as vwdylgbir text.

Heads up... You’re accessing parts of this content for free, with some sections shown as njkonqjyk text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
var goalReached: Bool {
  if let goal = goal,
    steps >= goal, !caught {
      return true
  }
  return false
}

Challenge

In StepCountControllerTests.tearDownWithError(), there are separate calls to reset the AppModel and the DataModel. Since the data model is a property of the app model, refactor the data model reset into AppModel.restart(), along with the appropriate tests.

Key points

  • Test methods require calling a XCTAssert function.
  • View controller logic can be separated in to data/state functions, which can be unit tested and view setup and response functions, which should be tested by UI automation.
  • Test execution order matters.
  • The code coverage reports can be used to make sure all branches have a minimum level of testing.
  • Test failure breakpoints are a tool on top of regular debugging tools for fixing tests.

Where to go from here?

For more on code coverage, this video tutorial covers that topic. And you can learn everything and more about debugging from the Advanced Apple Debugging & Reverse Engineering book. The tools and techniques taught in that tome are just as applicable to test code as application code.

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.
© 2025 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as wpzogxhur text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now