How to Test an iOS App With Unit Tests

ProfilePicture of Andres Canal
Andres Canal
Senior Full-stack Mobile Developer
A blue and black ladybug on a mobile phone

Project managers have always had a complicated relationship with testing. In the early days, it was considered a necessary evil that slowed the release process – but saved money in the long term. Technology has moved on since those days so, in the age of mobile development, is testing (and in particular unit testing) still the universal panacea it once was?

Table Of Contents

Relationship Status: It’s Complicated

It’s not just managers that have a complicated relationship with testing, developers do as well. The friction stems from a range of egotistical and psychological sources. A simple question illustrates this nicely: “What would you rather spend your time doing?”

I thought so, and you’re not the only one. Testing just doesn’t have the ‘coolness’ factor – and so doesn’t appeal to the ego.

Mobile testing, which we will be focussing on in this article, has its own specific problems. I often hear from colleagues how “mobile testing is harder than testing on a server”. And there is some truth to this. Usually, newbie iOS developers assume that testing a mobile app means simulating every step a user might perform. While that’s one way of testing, it’s probably the hardest. It’s also rarely necessary. If your app is well modularized, with low coupling between different parts, you can carry out the same type of tests as a developer writing server code.

Ultimately, what it comes down to is knowing what’s worth testing and what isn’t. This article will help you answer that question. It’s a mobile testing ‘bare minimum’ guide to readying an iOS app for release. One that balances best practices with developing in the real world.

Mobile Testing 101

Let’s take a look at some key vocabulary. To start with, there are three types of testing you need to be aware of. There are multiple options here, for the same reason that there are different types of cars. If you need to move heavy equipment, you might select a pick-up. But if you have to ferry the kids to soccer practice, then a minivan is your go-to option.

Unit Testing: To test a specific piece of functionality that is isolated from everything else. This would be the case if you have to test that a calculation or procedure is being done correctly.

Integration Testing: To test multiple components at the same time. Let’s imagine an app needs to download multiple records from a web server (the ServerService module) and store them in a database (the DataStoreService module). Testing how both work together is integration testing.

Functional Testing: These are product-oriented tests that check if the product is meeting defined requirements. For example, if your app needs to show a bank statement.

Framework: XCTest is the testing framework that comes bundled and fully integrated with Xcode for iOS. It will provide us with the basic structure of a test, and also give us all the methods needed for our test assertions.
Coverage: This metric tells you how much of your codebase has been executed while running tests. It’s expressed as a percentage: 80% coverage, 20% coverage, etc. It’s a handy metric as it helps show which parts of your code have never been executed under test conditions. That being said, having 100% coverage doesn’t mean that your code is definitely bug-free. It simply means that the totality of your code has been executed while testing. There could still be a group of conditions that have not been executed in a specific order that might produce a bug. It’s a bit counter-intuitive I know.

Last on the list of essential vocab is assertions. These are expressions that encapsulate testable logic about a target under test. Simply put, it’s a condition that we will use to be sure a test failed or succeeded. For example:

1XCTAssertEqual(someVariable, 0)

Here we are making sure that someVariable is equal to 0. If someVariable was 5, then the above test would have failed.

Testing On iOS

To test an app, we’re going to need one. So I created this simple (OK, and ugly) budgeting app that will serve our purposes perfectly – but it probably won’t rank at the top of the app store! We’ll be running the app through all the above tests, including the coverage report. Essentially, we’ll be refactoring the code from a total mess to easily testable.

Screenshot of a mobile iOS app

The code driving this app screen is below. It makes a network request and shows the returned information in a UITableView where each cell is a project with its own details. Let’s take a look at the code:

1import UIKit
3class ViewController: UIViewController {
5 @IBOutlet weak var tableView: UITableView!
6 var projects: [[String: Any]] = []
8 override func viewDidLoad() {
9 super.viewDidLoad()
10 tableView.delegate = self
11 tableView.dataSource = self
13 loadData()
14 }
16 func loadData() {
17 let url = URL(string: "")!
18 URLSession.shared.dataTask(with: url) { data, response, error in
19 guard let data = data else { return }
21 self.projects = try! JSONSerialization.jsonObject(
22 with: data,
23 options: []
24 ) as! [[String: Any]]
26 DispatchQueue.main.async {
27 self.tableView.reloadData()
28 }
29 }.resume()
30 }
33extension ViewController: UITableViewDataSource, UITableViewDelegate {
35 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
36 return projects.count
37 }
39 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
40 let cell = tableView.dequeueReusableCell(
41 withIdentifier: "ExpenseDetailTableViewCell",
42 for: indexPath
43 ) as! ExpenseDetailTableViewCell
45 let project = projects[indexPath.row]
47 let budget = Double(project["budget"] as! String)!
49 cell.projectNameLabel.text = project["name"] as! String
50 cell.budgetLabel.text = String(budget)
52 let expenses = project["expenses"] as! [[String: String]]
53 let totalExpenses = expenses.reduce(0) { (result, nextExpense) -> Double in
54 let amount = Double(nextExpense["amount"]!)!
55 return result + amount
56 }
58 if budget >= totalExpenses {
59 cell.budgetLabel.textColor =
60 } else {
61 cell.budgetLabel.textColor =
62 }
64 let dateString = project["start_date"] as! String
65 let formatter = ISO8601DateFormatter()
66 let date = dateString)!
68 let dateFormatter = DateFormatter()
69 dateFormatter.dateStyle = .medium
70 dateFormatter.timeStyle = .none
73 cell.startDateLabel.text = dateFormatter.string(from: date)
74 cell.statusLabel.text = project["status"] as! String
76 return cell
77 }

The data returned from the server looks like this:

3 {
4"name": "Fisherman Coats",
5"start_date": "2018-01-01T20:01:39+00:00",
6"due_date": "2018-05-17T20:01:39+00:00",
7"status": "finished",
8"budget": "10000.00",
9"expenses": [
11"name": "Pencils",
12"amount": "100.00"
16"name": "Fabric",
17"amount": "120.00"
21"name": "Sewing Material",
22"amount": "25.00"
25 }

The above code is bad. In fact, it’s so bad it’s untestable. Everything is tightly coupled and we are violating the single responsibility principle: each module should do just one thing and do it right. But that’s pretty much the point of this article, so don’t hold it against me!

De-Cluttering the View Controller

In our app, the view controller is doing the following tasks:

The thing is, it should only be doing that last point. Let’s get rid of all the parsing and accessing the project data from a dictionary, we are going to do this by creating a model. A model is a representation of ‘something’: in this case a project. So we need to create a model called Project that is going to hold all the information related to the project. This project also has expenses so there we have another model called Expense.

Now we need a way of populating those models with the server data we fetched. This can be done manually, but it’s pretty clunky as you have to check if each field exists. A better option is to use Codable and ObjectMapper. So that’s what I’ll do.

I use Codable to get rid of the JSON parsing in the view controller. I won’t break this process down, as the article would stretch into an ebook. Instead, I suggest you check out Apple’s documentation. It’s OK, I’ll wait.

[ elevator music interlude ]

Great, so now our models are fully codable:

1enum Status: String, Codable{
2 case finished
3 case running
6class Project: Codable {
7 var name: String?
8 var status: Status?
9 var budget: String?
10 var startDate: Date?
11 var dueDate: Date?
12 var expenses: [Expense] = []
13 var budgetValue: Double {
14 return Double(budget ?? "0") ?? 0.0
15 }
17 var totalExpenses: Double {
18 return expenses.reduce(0) { $0 + $1.amountValue }
19 }
21 private enum CodingKeys : String, CodingKey {
22 case name, status, budget, startDate = "start_date", dueDate = "due_date", expenses = "expenses"
23 }
26class Expense: Codable {
27 var name: String?
28 var amount: String?
29 var amountValue: Double {
30 return Double(amount ?? "0") ?? 0.0
31 }

Each model is in its own Swift file: Project.swift and Expense.swift. Just as it should be.

How do we know if this is going to extract the values (from the JSON) and place them in the correct property? There are two possible approaches here. See if you can work out which one is optimal.

Options 2 it was! If you carry that out, you should see a folder called ExpensesTests in the Project Navigator window – that’s where our tests will live.

Screenshot of the iOS Project Navigator window

We’ll add two files there, one for each model:

When adding the new files, you’ll choose the Unit Test Case Class and make sure you select the test target when adding the files. The tests we’ll perform on the Expense model will be in the ExpensesTests file and the ones for the Project model will be in the ProjectTests file. I try and follow the following rule of thumb: always name your test files with the class you want to test, followed by the word ‘Tests’.

The test files are subclasses of XCTest. All the methods written inside that class that start with the word “test” are going to be run by Xcode when testing.

Writing Our First Test!

First, we need to figure out how to present our test with data – and then we’ll decide what data to use. This is actually quite simple: we start by creating a JSON file, add that file to our test target, read that file and finally provide that data as the source data for our test.

We’ll use the same ‘read-file approach’ in multiple places. This means that, instead of writing a function every time,  we’ll just put that method in an XCTestCase extension. So it will always be available to our XCTestCase subclasses.

1extension XCTestCase {
2 func dataFrom(filename: String) -> Data {
3 let path = Bundle(for: ProjectTests.self).path(forResource: filename, ofType: "json")!
4 return NSData(contentsOfFile: path)! as Data
5 }

Now we need to create a JSON file that is a representation of the Project model. We’ll call it project.json. This is the same JSON representation that the server would return if we request the list of projects:

This is what that JSON file looks like:

2 "name": "Beach Cleaning",
3 "start_date": "2018-01-01T20:01:39+00:00",
4 "due_date": "2018-12-17T20:01:39+00:00",
5 "status": "running",
6 "budget": "3000.00",
7 "expenses": [
8 {
9 "name": "Work permits",
10 "amount": "200.00",
11 }
12 ]

And finally, what we have been waiting for, a real test:

1class ProjectTests: XCTestCase {
2 func testProjectCreation() {
3 let data = dataFrom(filename: "project")
4 let decoder = JSONDecoder()
5 decoder.dateDecodingStrategy = .iso8601
6 let project = try! decoder.decode(Project.self, from: data)
7 XCTAssertEqual(, "Beach Cleaning")
8 XCTAssertEqual(project.status, Status.running)
9 XCTAssertEqual(project.budget, "3000.00")
10 XCTAssertEqual(project.budgetValue, 3000.00)
11 XCTAssertEqual(project.dueDate?.timeIntervalSince1970, 1545076899.0)
12 XCTAssertEqual(project.startDate?.timeIntervalSince1970, 1514836899.0)
13 XCTAssertNotNil(project.expenses)
14 XCTAssertEqual(project.totalExpenses, 200.00)
15 }

Time for a quick recap. We’ve tested that we are correctly filling a model object with the information sent to us by the server. We’ve also tested a small operation: getting the total expenses for the project.

Seems like a good time for another pop quiz! If we want to classify this test into a category, which would it be? Answer: we are testing an isolated and very specific functionality, so this is a Unit Test.

Once I have rewritten the view controller to use the models we just created, it will look like this:

1import UIKit
3class ViewController: UIViewController {
4 @IBOutlet weak var tableView: UITableView!
5 var projects: [Project] = []
6 override func viewDidLoad() {
7 super.viewDidLoad()
8 tableView.delegate = self
9 tableView.dataSource = self
10 loadData()
11 }
13 func loadData() {
14 let url = URL(string: "http://localhost:8000/data.json")!
15 URLSession.shared.dataTask(with: url) { data, response, error in
16 let decoder = JSONDecoder()
17 decoder.dateDecodingStrategy = .iso8601
18 guard
19 let data = data,
20 let projects = try? decoder.decode([Project].self, from: data)
21 else { return }
22 self.projects = projects
23 DispatchQueue.main.async {
24 self.tableView.reloadData()
25 }
26 }.resume()
27 }
30extension ViewController: UITableViewDataSource, UITableViewDelegate {
31 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
32 return projects.count
33 }
35 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
36 let cell = tableView.dequeueReusableCell(
37 withIdentifier: "ExpenseDetailTableViewCell",
38 for: indexPath
39 ) as! ExpenseDetailTableViewCell
40 let project = projects[indexPath.row]
41 cell.projectNameLabel.text =
42 cell.budgetLabel.text = project.budget
43 if project.budgetValue >= project.totalExpenses {
44 cell.budgetLabel.textColor =
45 } else {
46 cell.budgetLabel.textColor =
47 }
48 if let startDate = project.startDate {
49 let dateFormatter = DateFormatter()
50 dateFormatter.dateStyle = .medium
51 dateFormatter.timeStyle = .none
52 cell.startDateLabel.text = dateFormatter.string(from: startDate)
53 }
54 cell.statusLabel.text = project.status?.rawValue
55 return cell
56 }

The main difference in the above code is how we are accessing the data. Previously, we were picking keys from a dictionary, before casting the values we assumed were correct. We were also making some calculations in the view controller. Now we have a cleaner view controller – even better – we have a small part of our app tested!

But there is still something that bothers me, something we’re not testing at all: the network call. We’re making a server call from the view controller and that’s not the right way to do it. It’s also impossible to test when done this way. One way of fixing this is to create a class that isolates this behavior. This then allows us to add tests while removing the network call from the view controller. Win-Win!

So let’s create a ServerService class which will hold all our network calls:

1class ServerService {
2 func fetchProjects(completion: @escaping ([Project])-> Void) {
3 let url = URL(string: "http://localhost:8000/data.json")!
4 URLSession.shared.dataTask(with: url) { data, response, error in
5 let decoder = JSONDecoder()
6 let projects = try? decoder.decode([Project].self, from: data)
7 guard
8 let data = data,
9 let projects = try? decoder.decode([Project].self, from: data)
10 else { return }
11 DispatchQueue.main.async {
12 completion(projects)
13 }
14 }.resume()
15 }

Now let’s test the service! This isn’t as straightforward as last time, because this time around we’re using an external network call. And as with any external dependency, we’ll need to find a way to stub it.

I’ve been trying to find the easiest way to define stubbing, and after reading almost every definition (editor: a slight exaggeration I’m sure!) on the web I opted for the one on Wikipedia: Test stubs provide canned answers to calls made during testing.

So, stubbing a network call means finding a way to make our DataTask return a fixed answer – without actually making a server call. The easiest way to do this is with OHHTTPStubs, which is extremely handy because you can integrate it using any of the most common dependency managers. If you want to learn more on dependency managers, take a look at my recent blog article here.

I won’t explain in-depth how to use OHHTTPStubs (for that click here). But I will show you how to test with it!

Start by creating a ServerServiceTests in the ExpensesTest target:

1class ServerServiceTests: XCTestCase {
2 let serverService = ServerService()
3 func testFetchProjects() {
4 let expectation = XCTestExpectation(description: "Fetching data from server")
5 // 1.
6 stub(condition: isHost("localhost") && isPath("/data.json") ) { _ in
7 let stubPath = OHPathForFile("full_response.json", type(of: self))
8 return fixture(
9 filePath: stubPath!,
10 status: 200,
11 headers: ["Content-Type":"application/json"]
12 )
13 }
14 serverService.fetchProjects { projects in
15 // 2.
16 XCTAssertEqual(projects.count, 3)
17 XCTAssertEqual(projects.first?.budgetValue, 10000.0)
18 expectation.fulfill()
19 }
20 wait(for: [expectation], timeout: 2)
21 }
22 override func tearDown() {
23 // 3.
24 OHHTTPStubs.removeAllStubs()
25 }

I’ll explain the above snippet step by step as there is a lot going on:

Refactoring the View Controller

Our ServerService is tested and working, so the only thing left is to refactor our ViewController! We’ll start by getting rid of the content from the loadData method, and putting our tested service in its place!

1class ViewController: UIViewController {
2 @IBOutlet weak var tableView: UITableView!
3 var projects: [Project] = []
4 let serverService = ServerService()
5 override func viewDidLoad() {
6 super.viewDidLoad()
7 tableView.delegate = self
8 tableView.dataSource = self
9 loadData()
10 }
11 func loadData() {
12 serverService.fetchProjects { [weak self] projects in
13 guard let self = self else { return }
14 self.projects = projects
15 self.tableView.reloadData()
16 }
17 }
20extension ViewController: UITableViewDataSource, UITableViewDelegate {
21 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
22 return projects.count
23 }
24 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
25 let cell = tableView.dequeueReusableCell(
26 withIdentifier: "ExpenseDetailTableViewCell",
27 for: indexPath
28 ) as! ExpenseDetailTableViewCell
29 let project = projects[indexPath.row]
30 cell.projectNameLabel.text =
31 cell.budgetLabel.text = project.budget
32 if project.budgetValue >= project.totalExpenses {
33 cell.budgetLabel.textColor =
34 } else {
35 cell.budgetLabel.textColor =
36 }
37 if let startDate = project.startDate {
38 let dateFormatter = DateFormatter()
39 dateFormatter.dateStyle = .medium
40 dateFormatter.timeStyle = .none
41 cell.startDateLabel.text = dateFormatter.string(from: startDate)
42 }
43 cell.statusLabel.text = project.status?.rawValue
44 return cell
45 }

Where does that leave us? The ViewController is only doing view-related stuff – which is how it should be. It’s not making server calls, it’s not parsing JSON, and it’s not doing calculations. All our models and server calls are also tested and working as expected. When we run a test, now we should see something like this:

Screenshot of viewcontroller with tests

But how do we know if anything still needs to be tested? This is where coverage comes into play.

To be able to see code coverage statistics, we’ll need to enable it. Go to  Product › Scheme › Edit Scheme and check the ‘Gather coverage for all targets’ option:

Screenshot of Xcode testing settings

Once that setting is in place, every time a test is run, we’ll see a new ‘Coverage’ category in the report navigator.

Screenshot of Xcode coverage report

In our example, ServerService is fully tested, but Expense.swift and Project.swift are not. How is this possible considering we tested all the methods in each class? Expand the Expense.swift and Project.swift line items and you’ll see the more detailed coverage information.

Screenshot of testing completion

Here we can see that some edge cases were skipped. Double click on Project.swift and Xcode will take you directly to that class.

Screenshot of Xcode code that needs testing

Here you can see what still needs to be tested. Let’s walk through this process:

Let’s remedy this by adding the missing text to provide a budget variable that is ‘nil’ and a budget variable that is a ‘non-valid’ number.

1func testProjectEmptyBudget() {
2 let data = dataFrom(filename: "project")
3 let decoder = JSONDecoder()
4 decoder.dateDecodingStrategy = .iso8601
5 let project = try! decoder.decode(Project.self, from: data)
6 project.budget = nil
7 XCTAssertEqual(project.budgetValue, 0)
10func testProjectWrongBudget() {
11 let data = dataFrom(filename: "project")
12 let decoder = JSONDecoder()
13 decoder.dateDecodingStrategy = .iso8601
14 let project = try! decoder.decode(Project.self, from: data)
15 project.budget = "this is unexpected value"
16 XCTAssertEqual(project.budgetValue, 0)

I also removed all the unused methods from the AppDelegate, and added missing tests to Expense.swift. So now the coverage report looks like this:

Screenshot of App testing completion

When Do You Stop Mobile Testing?

We currently have 68% coverage. Is that good enough? Well, that depends. If you are writing code for a flight navigation system where your code is responsible for keeping an airplane in the air, I would say no, 68% is not enough. I certainly wouldn’t board that plane. This question of ‘When is enough, enough?” is the million dollar question in testing. There is no bright line here, which I why I always answer with “it depends.”

Most of the time, testing the core parts of your application is enough. By ‘core parts’, I mean all the fundamental pieces of code that are going to be used again and again by your app. That’s because you should always build on top of solid code. In our example, we tested all of the core parts of the application. The only thing that is not completely tested is the ViewController. Does it make sense to test the ViewController? I would say no, I don’t want to waste energy on something that is probably going to change quite often (new designs, a different way of displaying the information, etc.).

In the end, it’s always going to come down to a judgment call. And the more you develop and test the better you will get at making that call.


We covered a lot in this mobile testing article. So I think a quick recap is in order:

Mobile development is a relatively immature field (compared to web app development anyway). I see this as an opportunity to change perceptions. We can write best-practice documentation that shows the true value of integrating mobile testing into the development process without the OCD baggage that is normally associated with it!

Originally published on Mar 13, 2020Last updated on Oct 28, 2022

Looking to hire?

Join our newsletter

Join thousands of subscribers already getting our original articles about software design and development. You will not receive any spam, just great content once a month.


Read Next

Browse Our Blog