Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

First Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

3. TDD App Setup
Written by Michael Katz

By now, you should be either 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.

The goal of this chapter is to 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 book section, you’ll build up 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 goal of the app is to motivate user movement by having them outpace Nessie. If they fail, their avatar gets eaten.

Start with the starter project for Chapter 3. This is a shell app. It comes with some things already wired up to save you some busy work. It’s mostly bare-bones since the goal is to lead development with writing tests. If you build and run, the app won’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 it’s executed during the test phase. It’s built alongside the app, but is not included in the app bundle.

This means your test code can contain code that doesn’t ship to your users. Just because your users don’t see this code isn’t an excuse to 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 the FitNess project in the Project navigator to show the 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 iOS Unit Testing Bundle. Click Next.

Did you notice the other bundle — iOS UI Testing Bundle? This is another type of testing. It uses automation scripting to verify views and app state. This type of testing is not 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 and the Target to be Tested is FitNess. Then click Finish.

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

Figuring out what to test

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

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

The TDD process requires writing a test first. This means you have to determine 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 different states the app can be in. The AppModel class holds the knowledge of which state the app is currently in.

The minimum functionality to start the app is to have the Start button put the app into a started, or in-progress, state. There are two statements that can be made to support this goal:

  1. The app should start off in the .notStarted state. This will allow the UI to render the welcome messaging.
  2. When the user taps the Start button, the app should move into the .inProgress state so the app can start tracking 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 on 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 eponymous 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(), and ignore setUp() and tearDown() for now.

Red-Green-Refactor

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

  1. Write a test that fails (red).
  2. Write the minimum amount of code so the test passes (green).
  3. Clean up test(s) and code as needed (refactor).
  4. Repeat the process until all the logic cases are covered.

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 actually performs the assertion that the state matches the expected value. More on that in a little bit.

Next, run the test.

Xcode provides several way of running a test:

  • You can click the diamond next to an individual test in the line number bar. This runs just that test.

  • You can click the diamond next to the class definition. This runs all the tests in the file.
  • You can click the Play button at the right of a test or test class in the Test navigator. This will run an individual test, a whole test file, or all the tests in a test target.
  • You can use the Product ▸ Test menu action (Command + U). This runs all the tests in the scheme. Right now, there is one test target, so it would just run all the tests in FitNessTests.
  • You can press Control + Option + Commanda + U. This will run 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 should receive two compilation error, which means this is a failing test! Congratulations! A failing test is the first step of TDD! Remember that red is not 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 have the ability to import them as if it were a framework. Like frameworks, they have to be imported in each Swift file, so the compiler is aware of what the app contains.

If the compile error unresolved identifier 'AppModel' 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, it should be complaining about Value of type 'AppModel' has no member 'appState'.

Go to AppModel.swift and add this variable to the class directly above init():

public var appState: AppState = .notStarted

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

Congrats, you now have a green test! This is a trivial test: 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 application 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, AppState.inProgress)
}

This test is broken 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 step is to 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! This is obvious since start() has no code. Add the minimum code to this method so the test passes:

appState = .inProgress

Run the tests again, and the test passes!

Note: It’s straightforward 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. Writing the minimum amount of code so the test passes cannot be skipped, though. It’s essential to the TDD process and is what ensures adequate coverage.

Test nomenclature

Some TDD nomenclature and naming best practices were followed for these tests. Take a look again 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 in test logs. With a large test suite that runs in a continuous integration rig, you’ll be able to just look at the test failures and know what the problem is. Avoid creating tests named test1, test2, etc.

The naming scheme used here has up to four parts:

  1. All tests must begin with test.
  2. AppModel This says an AppModel is the system under test (sut).
  3. whenStarted is the condition or state change that is the catalyst for the test.
  4. 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 to a specific condition. Any code that doesn’t flow naturally from the test name belongs in another test.

  1. let sut = AppModel()

This makes the system under test explicit by naming it sut. This test is in the AppModelTests test case subclass and this 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 is covering what happens when start() is called.

  1. let observedState = sut.appState

    Define a property that holds the value you observed while executing the application code.

  2. XCTAssertEqual(observedSate, AppState.inProgress)

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

This division of a test method is referred to as given/when/then:

  • The first part for a test is the things that are given. That is the initial state of 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 you’d like. What’s important is your write failing tests, add the code that makes the test pass, and refactor and repeat until the application is complete.

Structure of XCTestCase subclass

XCTest is in the family of test frameworks dervied 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 name starts with test that are part of a test case class. Test cases are grouped together 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 is executed when you run the test phase of a scheme.

Each test case class has a setUp() and tearDown() method that is used to set up global and class state before and after each test method is run. Unlike other XUnit implementations, XCTest does not have lifecycle methods that run just once for a whole test class or the test target.

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

  • XCTestCase subclass lifecycles are managed outside the test execution, and any class-level state is persisted between test methods.
  • The order in which test classes and test methods are run is not explicitly defined and cannot be relied upon.

Therefore, it’s important to use setUp() and tearDown() 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 sets aside storage for an AppModel to use in the tests. It’s force-unwrapped in this case because you do not have access to the class initializer. Instead, you have to set up variables at a later time; i.e., in the setUp() method.

Next, add the following to setUp():

super.setUp()
sut = AppModel()

Finally, remove the following:

let sut = AppModel()

In both testAppModel_whenInitialized_isInNotStartedState() and testAppModel_whenStarted_isInInProgressState().

Build and test. The tests should both still pass.

The second test modifies the appState of sut. Without the set up code, the test ordering could matter, because the first test asserts the initial state of sut. But now ordering does not matter, since sut is re-instantiated 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 important to clean up a test’s state after it’s run to control memory usage, clean up the filesystem, or otherwise put things back the way it was found.

Add the following to tearDown():

sut = nil
super.tearDown()

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 new global behavior added in the future won’t affect previous tests.

Your next set of tests

You’ve now added a little bit of application logic. But there is not yet any user-visible functionality. You need to wire up the Start button so that it changes app state and it’s reflected 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 out the app, they will become hard to find and maintain in one unorganized list. Unit tests are first class code and should have the same level of scrutiny as app code. That also 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, and these are organized in a parallel structure to the app code. This makes it really easy to navigate between the app class and its tests.

  • Mocks: For code that stands in for functional code, allowing for separating functionality from implementation. For example, network requests are commonly mocked. You’ll build these in later chapters.

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

Take the two classes already in the target and group them together 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 should 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 and delete the comments in setUp() and tearDown().

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

var sut: StepCountController!

If you build the test class now, you’ll see the following error: use of undeclared type 'StepCountController'. This is because the class is specified as internal because it doesn’t explicitly define access control.

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

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

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

@testable import FitNess

This 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 application or framework code. Now, the test can successfully build.

Next, update setUp() and tearDown() as follows:

override func setUp() {
  super.setUp()
  sut = StepCountController()
}

override func tearDown() {
  sut = nil
  super.tearDown()
}

Testing a state change

Now comes the fun part. There are two things to check when the user taps Start: First is that the app state updates, and the second is that the UI updates. Take each one in turn.

Add the following test method below tearDown():

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

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

This tests that when the startStopPause(_:) action is called, the app state will be inProgress.

Build and test, and you’ll get a test failure. This is because startStopPause is not implemented 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 whole separate kind of testing and not covered in this book. However, there plenty of UI aspects that can, and should, be unit tested.

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 performs the startStopPause(_:) action, but this time the test checks that the button text updates.

You may have noticed that this test is almost exactly the same as the previous one. It has the same initial conditions and “when” action. The important 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 is no ambiguity between multiple conditions. You’ll tackle cleaning up this kind of redundancy in later chapters.

Another good practice illustrated here is the use of 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.

As you can see from the lack of any other functionality, the app still has a way to go.

Testing initial conditions

The last two tests rely on certain initial conditions for its state. For example in testController_whenStartTapped_buttonLabelIsPause, the desire is to test for the transition from .notStarted to .inProgress. But the test could also pass if the view controller started out already in .inProgress.

Part of writing comprehensive unit tests is to make implicit assumptions into explicit assertions. Insert the following code between tearDown() 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.

Open StepCountController.swift and add the following at the end of viewDidLoad():

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 still to do with the two test classes made already. For example, AppModel is public when it should really 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 should be tested.
  • 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 in order for a unit test to pass. For the rest of the book, you’ll be over and over again following the red-green-refactor model. 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 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.