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 scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled 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.

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 {

}
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
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())
}
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()
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
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)
}

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)
}
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.

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
}
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.

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?”

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
}

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)
}
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.
© 2024 Kodeco Inc.

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

Unlock now