iOS & Swift Tutorials

Learn iOS development in Swift. Over 2,000 high quality tutorials!

iOS Unit Testing and UI Testing Tutorial

Learn how to add unit tests and UI tests to your iOS apps, and how you can check on your code coverage.

4.8/5 50 Ratings

Version

  • Swift 4.2, iOS 12, Xcode 10
Update note: Michael Katz updated this tutorial for Xcode 10.1, Swift 4.2, and iOS 12. Audrey Tam wrote the original.

Writing tests isn’t glamorous, but since tests keep your sparkling app from turning into a bug-ridden piece of junk, it’s necessary. If you’re reading this tutorial, you already know you should write tests for your code and UI, but you may not know how.

You may have a working app, but you want to test changes you’re making to extend the app. Maybe you already have tests written, but aren’t sure whether they’re the right tests. Or, you have started working on a new app and want to test as you go.

This tutorial will show you:

  • How to use Xcode’s Test navigator to test an app’s model and asynchronous methods
  • How to fake interactions with library or system objects by using stubs and mocks
  • How to test UI and performance
  • How to use the code coverage tool

Along the way, you’ll pick up some of the vocabulary used by testing ninjas.

Figuring Out What to Test

Before writing any tests, it’s important to know the basics. What do you need to test?

If your goal is to extend an existing app, you should first write tests for any component you plan to change.

Generally, tests should cover:

  • Core functionality: Model classes and methods and their interactions with the controller
  • The most common UI workflows
  • Boundary conditions
  • Bug fixes

Best Practices for Testing

The acronym FIRST describes a concise set of criteria for effective unit tests. Those criteria are:

  • Fast: Tests should run quickly.
  • Independent/Isolated: Tests should not share state with each other.
  • Repeatable: You should obtain the same results every time you run a test. External data providers or concurrency issues could cause intermittent failures.
  • Self-validating: Tests should be fully automated. The output should be either “pass” or “fail”, rather than rely on a programmer’s interpretation of a log file.
  • Timely: Ideally, tests should be written before you write the production code they test (Test-Driven Development).

Following the FIRST principles will keep your tests clear and helpful, instead of turning into roadblocks for your app.

Getting Started

Start by downloading the project materials using the Download Materials button at the top or bottom of this tutorial. There are two separate starter projects: BullsEye and HalfTunes.

  • BullsEye is based on a sample app in iOS Apprentice. The game logic is in the BullsEyeGame class, which you’ll test during this tutorial.
  • HalfTunes is an updated version of the sample app from the URLSession Tutorial. Users can query the iTunes API for songs, then download and play song snippets.

Unit Testing in Xcode

The Test navigator provides the easiest way to work with tests; you’ll use it to create test targets and run tests against your app.

Creating a Unit Test Target

Open the BullsEye project and press Command-6 to open the Test navigator.

Click the + button in the lower-left corner, then select New Unit Test Target… from the menu:

iOS Unit Testing: Test navigator

Accept the default name, BullsEyeTests. When the test bundle appears in the Test navigator, click to open the bundle in the editor. If the bundle doesn’t appear automatically, troubleshoot by clicking one of the other navigators, then return to the Test navigator.

iOS Unit Testing: Template

The default template imports the testing framework, XCTest, and defines a BullsEyeTests subclass of XCTestCase, with setUp(), tearDown(), and example test methods.

There are three ways to run the tests:

  1. Product ▸ Test or Command-U. Both of these run all test classes.
  2. Click the arrow button in the Test navigator.
  3. Click the diamond button in the gutter.

iOS Unit Testing: Running Tests

You can also run an individual test method by clicking its diamond, either in the Test navigator or in the gutter.

Try the different ways to run tests to get a feeling for how long it takes and what it looks like. The sample tests don’t do anything yet, so they run really fast!

When all the tests succeed, the diamonds will turn green and show check marks. You can click the gray diamond at the end of testPerformanceExample() to open the Performance Result:

iOS Unit Testing: Performance Results

You don’t need testPerformanceExample() or testExample() for this tutorial, so delete them.

Using XCTAssert to Test Models

First, you’ll use XCTAssert functions to test a core function of BullsEye’s model: Does a BullsEyeGame object correctly calculate the score for a round?

In BullsEyeTests.swift, add this line just below the import statement:

@testable import BullsEye

This gives the unit tests access to the internal types and functions in BullsEye.

At the top of the BullsEyeTests class, add this property:

var sut: BullsEyeGame!

This creates a placeholder for a BullsEyeGame, which is the System Under Test (SUT), or the object this test case class is concerned with testing.

Next, replace the contents of setup() with this:

super.setUp()
sut = BullsEyeGame()
sut.startNewGame()

This creates a BullsEyeGame object at the class level, so all the tests in this test class can access the SUT object’s properties and methods.

Here, you also call the game’s startNewGame(), which initializes the targetValue. Many of the tests will use targetValue to test that the game calculates the score correctly.

Before you forget, release your SUT object in tearDown(). Replace its contents with:

sut = nil
super.tearDown()
Note: It’s good practice creating the SUT in setUp() and releasing it in tearDown() to ensure every test starts with a clean slate. For more discussion, check out Jon Reid’s post on the subject.

Writing Your First Test

Now, you’re ready to write your first test!

Add the following code to the end of BullsEyeTests:

func testScoreIsComputed() {
  // 1. given
  let guess = sut.targetValue + 5

  // 2. when
  sut.check(guess: guess)

  // 3. then
  XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}

A test method’s name always begins with test, followed by a description of what it tests.

It’s good practice to format the test into given, when and then sections:

  1. Given: Here, you set up any values needed. In this example, you create a guess value so you can specify how much it differs from targetValue.
  2. When: In this section, you’ll execute the code being tested: Call check(guess:).
  3. Then: This is the section where you’ll assert the result you expect with a message that prints if the test fails. In this case, sut.scoreRound should equal 95 (100 – 5).

Run the test by clicking the diamond icon in the gutter or in the Test navigator. This will build and run the app, and the diamond icon will change to a green check mark!

Note: To see a full list of XCTestAssertions, go to Apple’s Assertions Listed by Category.

Debugging a Test

There’s a bug built into BullsEyeGame on purpose, and you’ll practice finding it now. To see the bug in action, you’ll create a test that subtracts 5 from targetValue in the given section, and leaves everything else the same.

Add the following test:

func testScoreIsComputedWhenGuessLTTarget() {
  // 1. given
  let guess = sut.targetValue - 5

  // 2. when
  sut.check(guess: guess)

  // 3. then
  XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}

The difference between guess and targetValue is still 5, so the score should still be 95.

In the Breakpoint navigator, add a Test Failure Breakpoint. This will stop the test run when a test method posts a failure assertion.

iOS Unit Testing: Adding a Test Failure Breakpoint

Run your test, and it should stop at the XCTAssertEqual line with a test failure.

Inspect sut and guess in the debug console:

iOS Unit Testing: Viewing a Test Failure

guess is targetValue - 5 but scoreRound is 105, not 95!

To investigate further, use the normal debugging process: Set a breakpoint at the when statement and also one in BullsEyeGame.swift, inside check(guess:), where it creates difference. Then run the test again, and step over the let difference statement to inspect the value of difference in the app:

iOS Unit Testing: Debug Console

The problem is that difference is negative, so the score is 100 – (-5). To fix this, you should use the absolute value of difference. In check(guess:), uncomment the correct line and delete the incorrect one.

Remove the two breakpoints, and run the test again to confirm that it now succeeds.

Using XCTestExpectation to Test Asynchronous Operations

Now that you’ve learned how to test models and debug test failures, it’s time to move on to testing asynchronous code.

Open the HalfTunes project. It uses URLSession to query the iTunes API and download song samples. Suppose you want to modify it to use AlamoFire for network operations. To see if anything breaks, you should write tests for the network operations and run them before and after you change the code.

URLSession methods are asynchronous: They return right away, but don’t finish running until later. To test asynchronous methods, you use XCTestExpectation to make your test wait for the asynchronous operation to complete.

Asynchronous tests are usually slow, so you should keep them separate from your faster unit tests.

Create a new unit test target named HalfTunesSlowTests. Open the HalfTunesSlowTests class, and import the HalfTunes app module just below the existing import statement:

@testable import HalfTunes

All the tests in this class use the default URLSession to send requests to Apple’s servers, so declare a sut object, create it in setUp() and release it in tearDown().

Replace the contents of the HalfTunesSlowTests class with:

var sut: URLSession!

override func setUp() {
  super.setUp()
  sut = URLSession(configuration: .default)
}

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

Next, add this asynchronous test:

// Asynchronous test: success fast, failure slow
func testValidCallToiTunesGetsHTTPStatusCode200() {
  // given
  let url = 
    URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  // 1
  let promise = expectation(description: "Status code: 200")

  // when
  let dataTask = sut.dataTask(with: url!) { data, response, error in
    // then
    if let error = error {
      XCTFail("Error: \(error.localizedDescription)")
      return
    } else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
      if statusCode == 200 {
        // 2
        promise.fulfill()
      } else {
        XCTFail("Status code: \(statusCode)")
      }
    }
  }
  dataTask.resume()
  // 3
  wait(for: [promise], timeout: 5)
}

This test checks that sending a valid query to iTunes returns a 200 status code. Most of the code is the same as what you’d write in the app, with these additional lines:

  1. expectation(description:): Returns an XCTestExpectation object, stored in promise. The description parameter describes what you expect to happen.
  2. promise.fulfill(): Call this in the success condition closure of the asynchronous method’s completion handler to flag that the expectation has been met.
  3. wait(for:timeout:): Keeps the test running until all expectations are fulfilled, or the timeout interval ends, whichever happens first.

Run the test. If you’re connected to the internet, the test should take about a second to succeed after the app loads in the simulator.

Failing Fast

Failure hurts, but it doesn’t have to take forever.

To experience failure, simply delete the ‘s’ from “itunes” in the URL:

let url = 
  URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")

Run the test. It fails, but it takes the full timeout interval! This is because you assumed the request would always succeed, and that’s where you called promise.fulfill(). Since the request failed, it finished only when the timeout expired.

You can improve this and make the test fail faster by changing the assumption: Instead of waiting for the request to succeed, wait only until the asynchronous method’s completion handler is invoked. This happens as soon as the app receives a response — either OK or error — from the server, which fulfills the expectation. Your test can then check whether the request succeeded.

To see how this works, create a new test.

But first, fix the previous test by undoing the change you made to the url.
Then, add the following test to your class:

func testCallToiTunesCompletes() {
  // given
  let url = 
    URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
  let promise = expectation(description: "Completion handler invoked")
  var statusCode: Int?
  var responseError: Error?

  // when
  let dataTask = sut.dataTask(with: url!) { data, response, error in
    statusCode = (response as? HTTPURLResponse)?.statusCode
    responseError = error
    promise.fulfill()
  }
  dataTask.resume()
  wait(for: [promise], timeout: 5)

  // then
  XCTAssertNil(responseError)
  XCTAssertEqual(statusCode, 200)
}

The key difference is that simply entering the completion handler fulfills the expectation, and this only takes about a second to happen. If the request fails, the then assertions fail.

Run the test. It should now take about a second to fail. It fails because the request failed, not because the test run exceeded timeout.

Fix the url, and then run the test again to confirm that it now succeeds.

Faking Objects and Interactions

Asynchronous tests give you confidence that your code generates correct input to an asynchronous API. You might also want to test that your code works correctly when it receives input from a URLSession, or that it correctly updates the user’s defaults database or an iCloud container.

Most apps interact with system or library objects — objects you don’t control — and tests that interact with these objects can be slow and unrepeatable, violating two of the FIRST principles. Instead, you can fake the interactions by getting input from stubs or by updating mock objects.

Employ fakery when your code has a dependency on a system or library object. You can do this by creating a fake object to play that part and injecting this fake into your code. Dependency Injection by Jon Reid describes several ways to do this.

Fake Input From Stub

In this test, you’ll check that the app’s updateSearchResults(_:) correctly parses data downloaded by the session, by checking that searchResults.count is correct. The SUT is the view controller, and you’ll fake the session with stubs and some pre-downloaded data.

Go to the Test navigator and add a new Unit Test Target. Name it HalfTunesFakeTests. Open HalfTunesFakeTests.swift and import the HalfTunes app module just below the import statement:

@testable import HalfTunes

Now, replace the content of the HalfTunesFakeTests class with this:

var sut: SearchViewController!

override func setUp() {
  super.setUp()
  sut = UIStoryboard(name: "Main", bundle: nil)
    .instantiateInitialViewController() as? SearchViewController
}

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

This declares the SUT, which is a SearchViewController, creates it in setUp() and releases it in tearDown():

Note: The SUT is the view controller, because HalfTunes has a massive view controller problem — all the work is done in SearchViewController.swift. Moving the networking code into a separate module would reduce this problem and, also, make testing easier.

Next, you’ll need some sample JSON data that your fake session will provide to your test. Just a few items will do, so to limit your download results in iTunes append &limit=3 to the URL string:

https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3

Copy this URL and paste it into a browser. This downloads a file named 1.txt, 1.txt.js or similar. Preview it to confirm it’s a JSON file, then rename it abbaData.json.

Now, go back to Xcode and go to the Project navigator. Add the file to the HalfTunesFakeTests group.

The HalfTunes project contains the supporting file DHURLSessionMock.swift. This defines a simple protocol named DHURLSession, with methods (stubs) to create a data task with either a URL or a URLRequest. It also defines URLSessionMock, which conforms to this protocol with initializers that let you create a mock URLSession object with your choice of data, response and error.

To set up the fake, go to HalfTunesFakeTests.swift and add the following in setUp(), after the statement that creates the SUT:

let testBundle = Bundle(for: type(of: self))
let path = testBundle.path(forResource: "abbaData", ofType: "json")
let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped)

let url = 
  URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
let urlResponse = HTTPURLResponse(
  url: url!, 
  statusCode: 200, 
  httpVersion: nil, 
  headerFields: nil)

let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil)
sut.defaultSession = sessionMock

This sets up the fake data and response and creates the fake session object. Finally, at the end, it injects the fake session into the app as a property of sut.

Now, you’re ready to write the test that checks whether calling updateSearchResults(_:) parses the fake data. Add the following test:

func test_UpdateSearchResults_ParsesData() {
  // given
  let promise = expectation(description: "Status code: 200")

  // when
  XCTAssertEqual(
    sut.searchResults.count, 
    0, 
    "searchResults should be empty before the data task runs")
  let url = 
    URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  let dataTask = sut.defaultSession.dataTask(with: url!) {
    data, response, error in
    // if HTTP request is successful, call updateSearchResults(_:) 
    // which parses the response data into Tracks
    if let error = error {
      print(error.localizedDescription)
    } else if let httpResponse = response as? HTTPURLResponse,
      httpResponse.statusCode == 200 {
      self.sut.updateSearchResults(data)
    }
    promise.fulfill()
  }
  dataTask.resume()
  wait(for: [promise], timeout: 5)

  // then
  XCTAssertEqual(sut.searchResults.count, 3, "Didn't parse 3 items from fake response")
}

You still have to write this as an asynchronous test because the stub is pretending to be an asynchronous method.

The when assertion is that searchResults is empty before the data task runs. This should be true, because you created a completely new SUT in setUp().

The fake data contains the JSON for three Track objects, so the then assertion is that the view controller’s searchResults array contains three items.

Run the test. It should succeed pretty quickly because there isn’t any real network connection!

Fake Update to Mock Object

The previous test used a stub to provide input from a fake object. Next, you’ll use a mock object to test that your code correctly updates UserDefaults.

Reopen the BullsEye project. The app has two game styles: The user either moves the slider to match the target value or guesses the target value from the slider position. A segmented control in the lower-right corner switches the game style and saves it in the user defaults.

Your next test will check that the app correctly saves the gameStyle property.

In the Test navigator, click on New Unit Test Class and name it BullsEyeMockTests. Add the following below the import statement:

@testable import BullsEye

class MockUserDefaults: UserDefaults {
  var gameStyleChanged = 0
  override func set(_ value: Int, forKey defaultName: String) {
    if defaultName == "gameStyle" {
      gameStyleChanged += 1
    }
  }
}

MockUserDefaults overrides set(_:forKey:) to increment the gameStyleChanged flag. Often, you’ll see similar tests that set a Bool variable, but incrementing an Int gives you more flexibility — for example, your test could check that the method is called only once.

Declare the SUT and the mock object in BullsEyeMockTests:

var sut: ViewController!
var mockUserDefaults: MockUserDefaults!

Next, replace the default setUp() and tearDown() with this:

override func setUp() {
  super.setUp()

  sut = UIStoryboard(name: "Main", bundle: nil)
    .instantiateInitialViewController() as? ViewController
  mockUserDefaults = MockUserDefaults(suiteName: "testing")
  sut.defaults = mockUserDefaults
}

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

This creates the SUT and the mock object and injects the mock object as a property of the SUT.

Now, replace the two default test methods in the template with this:

func testGameStyleCanBeChanged() {
  // given
  let segmentedControl = UISegmentedControl()

  // when
  XCTAssertEqual(
    mockUserDefaults.gameStyleChanged, 
    0, 
    "gameStyleChanged should be 0 before sendActions")
  segmentedControl.addTarget(sut,
    action: #selector(ViewController.chooseGameStyle(_:)), for: .valueChanged)
  segmentedControl.sendActions(for: .valueChanged)

  // then
  XCTAssertEqual(
    mockUserDefaults.gameStyleChanged, 
    1, 
    "gameStyle user default wasn't changed")
}

The when assertion is that the gameStyleChanged flag is 0 before the test method changes the segmented control. So, if the then assertion is also true, it means set(_:forKey:) was called exactly once.

Run the test; it should succeed.

UI Testing in Xcode

UI testing lets you test interactions with the User interface. UI testing works by finding an app’s UI objects with queries, synthesizing events, and then sending the events to those objects. The API enables you to examine a UI object’s properties and state in order to compare them against the expected state.

In the BullsEye project’s Test navigator, add a new UI Test Target. Check that the Target to be Tested is BullsEye, and then accept the default name BullsEyeUITests.

Open BullsEyeUITests.swift and add this property at the top of the BullsEyeUITests class:

var app: XCUIApplication!

In setUp(), replace the statement XCUIApplication().launch() with the following:

app = XCUIApplication()
app.launch()

Change the name of testExample() to testGameStyleSwitch().

Open a new line in testGameStyleSwitch() and click the red Record button at the bottom of the editor window:

iOS Unit Testing: Recording a UI Test

This opens the app in the simulator in a mode that records your interactions as test commands. Once the app loads, tap the Slide segment of the game style switch and the top label. Then, click the Xcode Record button to stop the recording.

You now have the following three lines in testGameStyleSwitch():

let app = XCUIApplication()
app.buttons["Slide"].tap()
app.staticTexts["Get as close as you can to: "].tap()

The Recorder has created code to test the same actions you tested in the app. Send a tap to the slider and the label. You’ll use those as a base to create your own UI test.
If you see any other statements, just delete them.

The first line duplicates the property you created in setUp(), so delete that line. You don’t need to tap anything yet, so also delete .tap() at the end of lines 2 and 3. Now, open the little menu next to ["Slide"] and select segmentedControls.buttons["Slide"].

What you are left with should be the following:

app.segmentedControls.buttons["Slide"]
app.staticTexts["Get as close as you can to: "]

Tap any other objects to let the recorder help you find the code you can access in your tests. Now, replace those lines with this code to create a given section:

// given
let slideButton = app.segmentedControls.buttons["Slide"]
let typeButton = app.segmentedControls.buttons["Type"]
let slideLabel = app.staticTexts["Get as close as you can to: "]
let typeLabel = app.staticTexts["Guess where the slider is: "]

Now that you have names for the two buttons in the segmented control, and the two possible top labels, add the following code below:

// then
if slideButton.isSelected {
  XCTAssertTrue(slideLabel.exists)
  XCTAssertFalse(typeLabel.exists)

  typeButton.tap()
  XCTAssertTrue(typeLabel.exists)
  XCTAssertFalse(slideLabel.exists)
} else if typeButton.isSelected {
  XCTAssertTrue(typeLabel.exists)
  XCTAssertFalse(slideLabel.exists)

  slideButton.tap()
  XCTAssertTrue(slideLabel.exists)
  XCTAssertFalse(typeLabel.exists)
}

This checks to see whether the correct label exists when you tap() on each button in the segmented control. Run the test — all the assertions should succeed.

Performance Testing

From Apple’s documentation: A performance test takes a block of code that you want to evaluate and runs it ten times, collecting the average execution time and the standard deviation for the runs. The averaging of these individual measurements form a value for the test run that can then be compared against a baseline to evaluate success or failure.

It’s very simple to write a performance test: You just place the code you want to measure into the closure of the measure().

To see this in action, reopen the HalfTunes project and, in HalfTunesFakeTests.swift, add the following test:

func test_StartDownload_Performance() {
  let track = Track(
    name: "Waterloo", 
    artist: "ABBA",
    previewUrl: 
      "http://a821.phobos.apple.com/us/r30/Music/d7/ba/ce/mzm.vsyjlsff.aac.p.m4a")

  measure {
    self.sut.startDownload(track)
  }
}

Run the test, then click the icon that appears next to the beginning of the measure() trailing closure to see the statistics.

iOS Unit Testing: Viewing a Performance Result

Click Set Baseline to set a reference time. Then, run the performance test again and view the result — it might be better or worse than the baseline. The Edit button lets you reset the baseline to this new result.

Baselines are stored per device configuration, so you can have the same test executing on several different devices, and have each maintain a different baseline dependent upon the specific configuration’s processor speed, memory, etc.

Any time you make changes to an app that might impact the performance of the method being tested, run the performance test again to see how it compares to the baseline.

Code Coverage

The code coverage tool tells you what app code is actually being run by your tests, so you know what parts of the app code aren’t (yet) tested.

To enable code coverage, edit the scheme’s Test action and check the Gather coverage for check box under the Options tab:

iOS Unit Testing: Setting the Code Coverage Switch

Run all tests (Command-U), then open the Report navigator (Command-9). Select Coverage under the top item in that list:

iOS Unit Testing: Code Coverage Report

Click the disclosure triangle to see the list of functions and closures in SearchViewController.swift:

iOS Unit Testing: Code Coverage Report

Scroll down to updateSearchResults(_:) to see that coverage is 87.9%.

Click the arrow button for this function to open the source file to the function. As you mouse over the coverage annotations in the right sidebar, sections of code highlight green or red:

iOS Unit Testing: Good and Bad Code Coverage

The coverage annotations show how many times a test hits each code section; sections that weren’t called are highlighted in red. As you’d expect, the for-loop ran 3 times, but nothing in the error paths was executed.

To increase coverage of this function, you could duplicate abbaData.json, then edit it so it causes the different errors. For example, change "results" to "result" for a test that hits print("Results key not found in dictionary").

100% Coverage?

How hard should you strive for 100% code coverage? Google “100% unit test coverage” and you’ll find a range of arguments for and against this, along with debate over the very definition of “100% coverage”. Arguments against it say the last 10-15% isn’t worth the effort. Arguments for it say the last 10-15% is the most important, because it’s so hard to test. Google “hard to unit test bad design” to find persuasive arguments that untestable code is a sign of deeper design problems.

Where to Go From Here?

You now have some great tools to use in writing tests for your projects. I hope this iOS Unit Testing and UI Testing tutorial has given you the confidence to test all the things!

You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial. Continue developing your skills by adding additional tests of your own.

Here are some resources for further study:

Average Rating

4.8/5

Add a rating for this content

50 ratings

Contributors

Comments