Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

3. TDD App Setup
Written by Michael Katz

By now, you should either be sold on Test-Driven Development (TDD) or at least curious. Following the TDD methodology helps you write clean, concise and correct code. This chapter will guide you through its fundamentals and give you a feel for how Xcode testing works by creating a test target with a few tests. You’ll do this while learning the key concepts of TDD.

By the end of the chapter, you’ll be able to:

  • Create a test target and run unit tests.
  • Write unit tests that verify data and state.
  • Feel comfortable with the TDD methodology:
    • Given, when, then.
    • System under test (sut).
    • Red/green/refactor of writing tests, then fixing code.

About the FitNess app

In this chapter, you’ll build a fun step-tracking app: FitNess. FitNess is the premier fitness-coaching app based on the Loch Ness workout. Users have to outrun, outswim or outclimb Nessie, the fitness monster. The app aims to motivate user movement by having them outpace Nessie. If they fail, Nessie eats their avatar.

Begin with the starter project for Chapter 3. This is a shell app. It comes with some things already wired to save you some busy work. It’s mostly bare-bones since the goal is to lead development with writing tests.

Build and run. You’ll see the app doesn’t do anything.

Your first test

First things first: You can’t run any tests without a test target. A test target is a binary that contains the test code and executes during the test phase.

While it’s built alongside the app, it’s not included in the app bundle, which means your test code can contain code that doesn’t ship to your users. However, just because your users won’t see this code doesn’t mean you can write lower-quality code.

The TDD philosophy treats tests as first-class code, meaning they should fit the same standards as your production code in terms of readability, naming, error handling and coding conventions.

Adding a test target

First, create a test target. Select FitNess in the Project navigator to show the project editor. Click the + button at the bottom of the targets list to add a new target.

Scroll down to the Test section and select Unit Testing Bundle. Click Next.

Did you notice the other bundle — UI Testing Bundle? UI testing uses automation scripting to verify views and app state. This type of testing isn’t necessary for adherence to TDD methodology and is outside the scope of this book.

On the next screen, double-check the Product Name is FitNessTests, the Language is Swift and the Target to be Tested is FitNess. Then click Finish.

Voila! You now have a FitNessTests target. Xcode also added a FitNessTests group in the Project navigator with FitNessTest.swift and an Info.plist for the target.

Deciding what to test

The unit test target template comes with a unit test class: FitNessTests. Ironically, it doesn’t actually test anything. Delete FitNessTests.swift.

Right now, the app does nothing since there’s no business logic. There’s only one button, and users expect that tapping Start will start the activity. Therefore, you should start with… Start.

The TDD process requires writing a test first, which means determining the smallest unit of functionality. This unit is where to start: The smallest thing that does something.

The App Model directory contains an AppState enum, which, not surprisingly, represents the app’s different potential states. The AppModel class contains the app’s current state.

The minimum functionality to start the app is having the Start button put the app into a started, or in-progress, state. Two statements support this goal:

  1. The app should start in .notStarted to let the UI render the welcome messaging.
  2. When the user taps Start, the app should move into .inProgress to track user activity and display updates.

The statements are actually assertions and what you’ll use to define test cases.

Adding a test class

Right-click FitNessTests in the project navigator. Select New File. In the iOS tab, select Unit Test Case Class and click Next.

Name the class AppModelTests. A good naming convention takes the name of the file or class you’re testing and appends the suffix: Tests. In this case, you’re writing tests for AppModel. Click Next.

Make sure the group is FitNessTests and only the FitNessTests target is checked. Click Create. If Xcode asks to create an Objective-C bridging header, click Don’t Create. There’s no Objective-C in this project.

You now have a fresh test class to start adding test cases. Delete the template methods testExample() and testPerformanceExample(). Ignore setUpWithError() and tearDownWithError() for now.

Red-Green-Refactor

The name of the game in TDD is red, green, refactor. To iteratively writing tests in this fashion, you:

  1. Write a test that fails (red).
  2. Write the minimum amount of code to make the test pass (green).
  3. Clean up test(s) and code as needed (refactor).
  4. Repeat the process until you cover all the logic cases.

Writing a red test

Add your first failing-to-compile test to the class:

func testAppModel_whenInitialized_isInNotStartedState() {
  let sut = AppModel()
  let initialState = sut.appState
  XCTAssertEqual(initialState, AppState.notStarted)
}

This method creates an app model and gets its appState. The third line of the test performs the assertion that the state matches the expected value— more on that in a little bit.

Next, run the test.

Xcode provides several ways to run a test:

  • Click the diamond next to an individual test in the line number bar. This method runs just that test.

  • Click the diamond next to the class definition to run all the tests in the file.
  • Click Play to the right of a test or test class in the Test navigator. This process runs an individual test, a whole test file or all the tests in a test target.
  • Use the Product ▸ Test menu action, or Command + U, to run all the tests in the scheme. Right now, there’s one test target, so it would only run all the tests in FitNessTests.
  • Press Control + Option + Command + U. This method runs the test function if the editor cursor is within a test function or the whole test file if the cursor is in a test file but outside a specific test function.

That’s a lot of ways to run a test! Choose whichever one you prefer to run your one test.

Before the test executes, you’ll receive two compilation errors, which means this is a failing test! Congratulations! A failing test is the first step of TDD! Remember that red isn’t just good, but necessary at this stage. If the test were to pass without any code written, then it’s not a worthwhile test.

Making the test green

The first issue with this test is the test code doesn’t know what the heck an AppModel is. Add this statement to the top of the file:

import FitNess

In Xcode, although application targets aren’t frameworks, they are modules, and test targets can import them as if they were a framework. Like frameworks, you have to import them in each Swift file, so the compiler is aware of what the app contains.

If the compile error cannot find 'AppModel' in scope doesn’t resolve itself, you can make Xcode rebuild the test target via the Product ▸ Build For ▸ Testing menu or the default keyboard shortcut Shift + Command + U.

You’re not done fixing compiler errors yet. Now, the compiler will complain about Value of type 'AppModel' has no member 'appState'.

Open AppModel.swift and add the following variable to the class directly above init():

public var appState: AppState = .notStarted

Run the test again. You’ll get a green checkmark next to the test since it passes. Notice how the only app code you wrote was to make that one pass.

Congrats, you now have a green test! This test is trivial: You’re testing the default state of an enum variable as the result of an initializer. That means, in this case, there’s nothing to refactor. You’re done!

Writing a more interesting test

The previous test asserted the app starts in a not started state. Next, assert the app can go from not started to in-progress.

Add the following test to the end of your class before the closing bracket:

func testAppModel_whenStarted_isInInProgressState() {
  // 1 given app in not started
  let sut = AppModel()

  // 2 when started
  sut.start()

  // 3 then it is in inProgress
  let observedState = sut.appState
  XCTAssertEqual(observedState, .inProgress)
}

You can break this test into three parts:

  1. The first line creates an AppModel. The previous test ensures the model initializes to .notStarted.
  2. The second line calls a yet-to-be-created start method.
  3. The last two lines verify the state should then be equal to .inProgress.

Run the tests. Once again, you have a red test that doesn’t compile. Next, you’ll fix the compiler errors.

Open AppModel.swift and add the following method below init():

public func start() {
}

Now, the app should compile. Run the tests.

The test fails! Obviously, it fails because start() has no code. Add the minimum code to start(), so the test passes:

appState = .inProgress

Run the tests again. The test passes!

Note: It’s straightforward logic that an empty start() fails the test. TDD is about discipline, and it’s good practice to strictly follow the process while learning. With more experience, it’s OK to skip the literal build and test step after getting the test to compile. However, you can’t skip writing the minimum amount of code so the test passes. It’s essential to the TDD process and is what ensures adequate coverage.

Test nomenclature

These tests follow some TDD nomenclature and naming best practices. Take another look at the second test, line-by-line:

  1. func testAppModel_whenStarted_isInInProgressState() {

The test function name should describe the test. The test name shows up in the test navigator and test logs. With a large test suite that runs in a continuous integration rig, you can just look at the test failures and see the problem. Avoid creating tests with non-descript names like test1 and test2.

The naming scheme used here has up to four parts:

i. All tests must begin with test.

ii. AppModel This says an AppModel is the system under test (sut).

iii. whenStarted is the condition or state change that is the catalyst for the test.

iv. isInInProgressState is the assertion about what the sut’s state should be after the when happens.

This naming convention also helps keep the test code focused on a specific condition. Any code that doesn’t flow naturally from the test name belongs in another test.

  1. let sut = AppModel()

Here, you make the system under test explicit by naming it sut. This test is in the AppModelTests test case subclass and is a test on AppModel. It may be slightly redundant, but it’s nice and explicit.

  1. sut.start()

This is the behavior to test. In this case, the test covers what happens when you call start().

  1. let observedState = sut.appState

    Here, you define a property that holds the value you observed while executing the app code.

  2. XCTAssertEqual(observedSate, .inProgress)

The last part is the assertion about what happens to sut when it starts. The stated logical assertions correspond directly in XCTest to XCTAssert functions.

You can divide a test method into given/when/then:

  • The first part of a test is the things that are given. That’s the initial state of the system.
  • The second part is the when, which is the action, event or state change that acts on the system.
  • The third part, or then, is testing the expected state after the when.

TDD is a process, not a naming convention. This book uses the convention outlined here, but you can still follow TDD on your own using whatever naming conventions you’d like. What’s important is your write failing tests, add the code that makes the test pass, then refactor and repeat until the application is complete.

Structure of XCTestCase subclass

XCTest is in the family of test frameworks derived from XUnit. Like so many good object-oriented things, XUnit comes from Smalltalk, where it was SUnit. It’s an architecture for running unit tests. The X is a stand-in for the programming language. For example, in Java, it’s JUnit, and in Objective-C, it’s OCUnit. In Swift, it’s just XCTest.

With XUnit, tests are methods whose names start with test and are part of a test case class. You can group test cases into a test suite. Test runner is a program that knows how to find test cases in the suite, run them and gather and display results. It’s Xcode’s test runner that executes when you run the test phase of a scheme.

Each test case class has a setUpWithError() and tearDownWithError() used to set up global and class state before and after each test method runs. Unlike other XUnit implementations, XCTest doesn’t have lifecycle methods that run only once for a whole test class or the test target.

These methods are essential because of a few subtle but extremely important gotchas:

  • You manage XCTestCase subclass lifecycles outside the test execution, and any class-level state persists between test methods.
  • Because it isn’t explicitly defined, you can’t rely on the order in which test classes and test methods run.

Therefore, it’s important to use setUpWithError() and tearDownWithError() to clean up and make sure state is in a known position before each test.

Setting up a test

Both tests need an AppModel() to test. It’s common for test cases to use a common sut object.

In AppModelTests.swift, add the following variable to the top of the class:

var sut: AppModel!

This variable sets aside storage for an AppModel to use in the tests. It’s force-unwrapped in this case because you don’t have access to the class initializer. Instead, you have to set up variables later, for example, in setUpWithError().

Next, update setUpWithError() to:

override func setUpWithError() throws {
  try super.setUpWithError()
  sut = AppModel()
}

Finally, in both testAppModel_whenInitialized_isInNotStartedState() and testAppModel_whenStarted_isInInProgressState(), remove:

let sut = AppModel()

Build and test. The tests both still pass.

The second test modifies the appState of sut. Without the setup code, the test order could matter because the first test asserts the initial state of sut. But for now, the order doesn’t matter since sut is re-instantiated for each test.

Tearing down a test

A related gotcha with XCTestCases is it won’t be deinitialized until all the tests are complete. That means it’s essential to clean up a test’s state after runs to control memory usage, clean up the file system or otherwise put things back the way you found them.

Update tearDownWithError():

override func tearDownWithError() throws {
  sut = nil
  try super.tearDownWithError()
}

So far, it’s a pretty simple test case, and the only persistent state is in sut, so clearing it in tearDown is good practice. It helps ensure that any new global behavior you add in the future won’t affect previous tests.

Your next set of tests

You added a little bit of app logic. But there isn’t any user-visible functionality yet. You need to wire Start to change the app state and reflect it to the user.

Hold up! This is test-driven development, and that means writing the test first.

Since StepCountController contains the logic for the main screen, create a new Unit Test Case Class named StepCountControllerTests in the FitNessTests target.

Test target organization

Take a moment to think about the test target organization. As you continue to add test cases when building the app, they’ll become hard to find and maintain in one unorganized list. Remember, unit tests are first-class code and should have the same level of scrutiny as app code. That means keeping them organized.

In this book, you’ll use the following organization:

Test Target
  ⌊ Cases
     ⌊ Group 1
        ⌊ Tests 1
        ⌊ Tests 2
     ⌊ Group 2
        ⌊ Tests
  ⌊ Mocks
  ⌊ Helper Classes
  ⌊ Helper Extensions
  • Cases: The group for the test cases. These are organized in a parallel structure to the app code, making it easy to navigate between the app class and its tests.

  • Mocks: For code that stands in for functional code, letting you separate functionality from implementation. For example, developers commonly mock network requests. You’ll build these in later chapters.

  • Helper classes and extensions: For additional code you write to make the test code easier to write, but which doesn’t directly test or mock functionality.

Put the two classes already in the target in a group named Cases.

Next, put AppModelTests.swift in a App Model group. Then put StepCountControllerTests.swift in a UI Layer group.

When it’s all done, your target structure will look like this:

As you add new tests, keep them organized in groups.

Using @testable import

Open StepCountControllerTests.swift.

Delete the testExample() and testPerformanceExample() stubs. Then delete the comments in setUpWithError() and tearDownWithError().

Next, add the following class variable above setUpWithError():

var sut: StepCountController!

Build the test class now. You’ll see an error, cannot find type 'StepCountController' in scope, because the class is specified as internal since it doesn’t explicitly define access control.

There are two ways to fix this error. The first is to declare StepCountController as public, making that class available outside the FitNess module and usable by the test class. However, this would violate SOLID principles by making the view visible outside of the app.

Fortunately, Xcode provides a way to expose data types for testing without making them available for general use through the @testable attribute.

Add the following to the top of the file, under import XCTest:

@testable import FitNess

This code makes symbols that are open, public, and internal available to the test case. Note that this attribute is only available in test targets and will not work in app or framework code. Now, the test can successfully build.

Next, update setUpWithError() and tearDownWithError() as follows:

override func setUpWithError() throws {
  try super.setUpWithError()
  sut = StepCountController()
}

override func tearDownWithError() throws {
  sut = nil
  try super.tearDownWithError()
}

Testing a state change

Now here comes the fun part! There are two things to check when the user taps Start: Does the app state update and does the UI update. Take each one in turn.

Add the following test method below tearDownWithError():

func testController_whenStartTapped_appIsInProgress() {
  // when
  sut.startStopPause(nil)

  // then
  let state = AppModel.instance.appState
  XCTAssertEqual(state, AppState.inProgress)
}

This method tests if the app state is inProgress when you call startStopPause().

Build and test. You’ll get a test failure because you haven’t implemented startStopPause yet. Remember, test failures at this point are good!

Open StepCountController.swift, and add the following code to startStopPause():

AppModel.instance.start()

Build and test again. Now the test passes!

Testing UI updates

UI testing with UI Automation is a different type of testing and is outside the scope of this book. However, there are plenty of UI aspects that can, and should, be unit tested. SwiftUI’s declarative nature means a view’s internal structure is deliberately difficult to access. UI Automation makes that task significantly easier, but you can also write valuable unit tests by separating the state-controlling logic from the view hierarchy.

Writing the test

Add the following test case at the bottom of StepCountControllerTests:

func testController_whenStartTapped_buttonLabelIsPause() {
  // when
  sut.startStopPause(nil)

  // then
  let text = sut.startButton.title(for: .normal)
  XCTAssertEqual(text, AppState.inProgress.nextStateButtonLabel)
}

Like the previous tests, this test performs startStopPause(). However, this time the test checks if the button text updates.

You may have noticed that this test is almost the same as the previous one. It has the same initial conditions and “when” action. The critical difference is that this tests a different state change.

TDD best practice is to have one assert per test. With well-named test methods, when the test fails, you’ll know exactly where the issue is because there’s no ambiguity between multiple conditions. You’ll tackle cleaning up this kind of redundancy in later chapters.

Another good practice illustrated here is using AppState.inProgress.nextStateButtonLabel instead of hard-coding the string. By using the app’s value, the assert is testing behavior and not a specific value. If the string changes or gets localized, the test won’t have to change to accommodate that.

Since this is TDD, the test will fail if you run it. Fix the test by adding the appropriate code to the end of startStopPause(_:):

let title = AppModel.instance.appState.nextStateButtonLabel
startButton.setTitle(title, for: .normal)

Now, build and test again for a green test. You can also build and run to try out the functionality.

Tapping the Start button turns it into a Pause button.

Next, add the following method below startStopPause():

Part of writing comprehensive unit tests is to make implicit assumptions into explicit assertions. Insert the following code between tearDownWithError() and testController_whenStartTapped_appIsInProgress():

// MARK: - Initial State

func testController_whenCreated_buttonLabelIsStart() {
  let text = sut.startButton.title(for: .normal)
  XCTAssertEqual(text, AppState.notStarted.nextStateButtonLabel)
}

// MARK: - In Progress

This test checks the button’s label after it’s created to make sure it reflects the .notStarted state.

This also adds some MARKs to the file to help divide the test case up into sections. As the classes get more complicated, the test files will grow quite large, so it’s important to keep them well organized.

Build and test. Hurray, another failure! Go ahead and fix the test.

The last two tests rely on certain initial conditions for their states. For example, in testView_whenStartTapped_buttonLabelIsPause, the desire is to test for the transition from .notStarted to .inProgress. But the test could also pass if the view started in .inProgress.

Part of writing comprehensive unit tests is to make implicit assumptions into explicit assertions. Insert the following code between tearDownWithError() and testController_whenStartTapped_appIsInProgress():

let title = AppState.notStarted.nextStateButtonLabel
startButton.setTitle(title, for: .normal)

The test is not quite ready yet to pass. Go back to the tests, and add at the top of testController_whenCreated_buttonLabelIsStart() the following lines:

// given
sut.viewDidLoad()

Now, build and test and the tests will pass. The call to viewDidLoad() is needed because the sut is not actually loaded from the xib and put into a view hierarchy, so the view lifecycle methods do not get called. You’ll see in Chapter 4, “Test Expressions,” how to get a properly loaded view controller for testing.

Refactoring

If you look at StepCountController.swift, the code that sets the button text is awfully redundant. When building an app using TDD, after you get all the tests to pass, you can then refactor the code to make it more efficient, readable, maintainable, etc. You can feel free to modify the both the app code and test code at will, resting easy because you have a complete set of tests to catch any issues if you break it.

Add the following method to the bottom of StepCountController:

private func updateButton() {
  let title = AppModel.instance.appState.nextStateButtonLabel
  startButton.setTitle(title, for: .normal)
}

This helper method will be used in multiple places in the file — whenever the button needs to reflect a change in app state. This can be private as this is an internal implementation detail of the class. The behavioral methods remain internal and can still be tested.

In viewDidLoad() and startStopPause(_:) replace the two lines that update the title with a call to updateButton().

Build and test. The tests will all still pass. Code was changed, but behavior was kept constant. Hooray refactoring! This type of refactoring is called Extract Method. There is a menu item to do it available in the Editor ▸ Refactor menu in Xcode.

You’re still a long way from a complete app with a full test suite, but you are on your way.

Challenge

There are a few things left to do with the two test classes you already made. For example, AppModel is public when it should be internal. Update its access modifier and use @testable import in AppModelTests.

And in StepCountControllerTests.swift there is a redundancy in the call to startStopPause(_:). Extract that out into a helper when method.

Key points

  • TDD is about writing tests before writing app logic.
  • Use logical statements to drive what you test.
  • Each test should fail upon its first execution. Not compiling counts as a failure.
  • Use tests to guide refactoring code for readability and performance.
  • Good naming conventions make it easier to navigate and find issues.

Where to go from here?

Test-driven development is pretty simple in its fundamentals: Only write app code for a unit test to pass. For the rest of the book, you’ll follow the red-green-refactor model over and over. You’ll explore more interesting types of tests and learn how to test things that aren’t obviously unit testable.

For more information specific to how Xcode works with tests and test targets, see the developer documentation. For a jam-packed overview on iOS testing, try out this free iOS Unit Testing and UI Testing tutorial.

In the next chapter, you’ll learn more about XCTAssert functions, testing view controllers, code coverage and debugging unit tests.

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.