Mobile Testing: A Guide to Testing Your iOS app

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?

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

  1. Learning how to display a table with data you fetched asynchronously from a remote server?
  2. Grinding your way through a list of tests to ensure your network stack is working as it should?

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 mobile 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 goto 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:

XCTAssertEqual(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.

iOS Mobile Testing App Demo

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:

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    var projects: [[String: Any]] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.delegate = self
        tableView.dataSource = self

        loadData()
    }

    func loadData() {
        let url = URL(string: "https://my-server.com/projects.json")!
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data else { return }

            self.projects = try! JSONSerialization.jsonObject(
                with: data,
                options: []
            ) as! [[String: Any]]

            DispatchQueue.main.async {
                self.tableView.reloadData()
            }
        }.resume()
    }
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return projects.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: "ExpenseDetailTableViewCell",
            for: indexPath
        ) as! ExpenseDetailTableViewCell

        let project = projects[indexPath.row]

        let budget = Double(project["budget"] as! String)!

        cell.projectNameLabel.text = project["name"] as! String
        cell.budgetLabel.text = String(budget)

        let expenses = project["expenses"] as! [[String: String]]
        let totalExpenses = expenses.reduce(0) { (result, nextExpense) -> Double in
            let amount = Double(nextExpense["amount"]!)!
            return result + amount
        }

        if budget >= totalExpenses {
            cell.budgetLabel.textColor = UIColor.green
        } else {
            cell.budgetLabel.textColor = UIColor.red
        }

        let dateString = project["start_date"] as! String
        let formatter = ISO8601DateFormatter()
        let date = formatter.date(from: dateString)!

        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .medium
        dateFormatter.timeStyle = .none


        cell.startDateLabel.text = dateFormatter.string(from: date)
        cell.statusLabel.text = project["status"] as! String

        return cell
    }
}

The data returned from the server looks like this:

[ 

  {

"name": "Fisherman Coats",

"start_date": "2018-01-01T20:01:39+00:00",

"due_date": "2018-05-17T20:01:39+00:00",

"status": "finished",

"budget": "10000.00",

"expenses": [

{

"name": "Pencils",

"amount": "100.00"

},

{

"name": "Fabric",

"amount": "120.00"

},

{

"name": "Sewing Material",

"amount": "25.00"

}

]

  } …

]

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:

  • Requesting data from the server.
  • Parsing and getting a dictionary out of this data.
  • Extracting and performing different actions to the data.
  • Displaying the information.

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 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:

enum Status: String, Codable{

    case finished

    case running

}

class Project: Codable {

    var name: String?

    var status: Status?

    var budget: String?

    var startDate: Date?

    var dueDate: Date?

    var expenses: [Expense] = []

    var budgetValue: Double {

        return Double(budget ?? "0") ?? 0.0

    }

    var totalExpenses: Double {

        return expenses.reduce(0) { $0 + $1.amountValue }

    }

    private enum CodingKeys : String, CodingKey {

        case name, status, budget, startDate = "start_date", dueDate = "due_date", expenses = "expenses"

    }

}

class Expense: Codable {

    var name: String?

    var amount: String?

    var amountValue: Double {

        return Double(amount ?? "0") ?? 0.0

    }

}

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.

  1. Run the app, put a breakpoint after the decoding happens and start printing out each object variable.
  2. Create a short unit test to check if everything works as intended.

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.

iOS Mobile Testing App Demo

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

  • ExpensesTests.swift
  • ProjectTests.swift.

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 Project model will be in 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.

extension XCTestCase {

    func dataFrom(filename: String) -> Data {

        let path = Bundle(for: ProjectTests.self).path(forResource: filename, ofType: "json")!

        return NSData(contentsOfFile: path)! as Data

    }

}

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:

{

    "name": "Beach Cleaning",

    "start_date": "2018-01-01T20:01:39+00:00",

    "due_date": "2018-12-17T20:01:39+00:00",

    "status": "running",

    "budget": "3000.00",

    "expenses": [

        {

             "name": "Work permits",

             "amount": "200.00",

        }

    ]

}

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

class ProjectTests: XCTestCase {

    func testProjectCreation() {

        let data = dataFrom(filename: "project")

        let decoder = JSONDecoder()

        decoder.dateDecodingStrategy = .iso8601

        let project = try! decoder.decode(Project.self, from: data)

        XCTAssertEqual(project.name, "Beach Cleaning")

        XCTAssertEqual(project.status, Status.running)

        XCTAssertEqual(project.budget, "3000.00")

        XCTAssertEqual(project.budgetValue, 3000.00)

        XCTAssertEqual(project.dueDate?.timeIntervalSince1970, 1545076899.0)

        XCTAssertEqual(project.startDate?.timeIntervalSince1970, 1514836899.0)

        XCTAssertNotNil(project.expenses)

        XCTAssertEqual(project.totalExpenses, 200.00)

    }

}

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:

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    var projects: [Project] = []

    override func viewDidLoad() {

        super.viewDidLoad()

        tableView.delegate = self

        tableView.dataSource = self

        loadData()

    }

    func loadData() {

        let url = URL(string: "http://localhost:8000/data.json")!

        URLSession.shared.dataTask(with: url) { data, response, error in

            let decoder = JSONDecoder()

 decoder.dateDecodingStrategy = .iso8601

            guard

                let data = data,

                let projects = try? decoder.decode([Project].self, from: data)

            else { return }

            self.projects = projects

            DispatchQueue.main.async {

                self.tableView.reloadData()

            }

        }.resume()

    }

}

extension ViewController: UITableViewDataSource, UITableViewDelegate {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

        return projects.count

    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(

            withIdentifier: "ExpenseDetailTableViewCell",

            for: indexPath

        ) as! ExpenseDetailTableViewCell

        let project = projects[indexPath.row]

        cell.projectNameLabel.text = project.name

        cell.budgetLabel.text = project.budget

        if project.budgetValue >= project.totalExpenses {

            cell.budgetLabel.textColor = UIColor.green

        } else {

            cell.budgetLabel.textColor = UIColor.red

        }

        if let startDate = project.startDate {

            let dateFormatter = DateFormatter()

            dateFormatter.dateStyle = .medium

            dateFormatter.timeStyle = .none

            cell.startDateLabel.text = dateFormatter.string(from: startDate)

        }

        cell.statusLabel.text = project.status?.rawValue

        return cell

    }

}

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:

class ServerService {

    func fetchProjects(completion: @escaping ([Project])-> Void) {

        let url = URL(string: "http://localhost:8000/data.json")!

        URLSession.shared.dataTask(with: url) { data, response, error in

            let decoder = JSONDecoder()

            let projects = try? decoder.decode([Project].self, from: data)

            guard

                let data = data,

                let projects = try? decoder.decode([Project].self, from: data)

                else { return }

            DispatchQueue.main.async {

                completion(projects)

            }

        }.resume()

    }

}

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 to my recent blog 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:

class ServerServiceTests: XCTestCase {

    let serverService = ServerService()

    func testFetchProjects() {

        let expectation = XCTestExpectation(description: "Fetching data from server")

        // 1.

        stub(condition: isHost("localhost") && isPath("/data.json") ) { _ in

            let stubPath = OHPathForFile("full_response.json", type(of: self))

            return fixture(

                filePath: stubPath!,

                status: 200,

                headers: ["Content-Type":"application/json"]

            )

        }

        serverService.fetchProjects { projects in

            // 2.

            XCTAssertEqual(projects.count, 3)

            XCTAssertEqual(projects.first?.budgetValue, 10000.0)

            expectation.fulfill()

        }

        wait(for: [expectation], timeout: 2)

    }

    override func tearDown() {

        // 3.

        OHHTTPStubs.removeAllStubs()

    }

}

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

  1. We started by setting our stub with OHHTTPStubs. Then we defined a condition so all requests made to the localhost(with the path /data.json) receive the content from the file full_response.json. This file contains the exact response the server would provide.
  2. Now for the actual test. We check the conditions to see if the response was as expected. Also, as this is an asynchronous test, we used expectations. Expectations let us test asynchronous code by defining an expectation, and then fulfilling that expectation when the test is correctly completed. At the same time, we defined a timeout, so if the timeout expires before the expectation is fulfilled, then the test will fail. The official documentation for expectations is quite straightforward, and you can take a look at it here.
  3. Lastly, we removed the stubs after the test was performed. This avoids conflicts with other tests using the same path.

Refactoring the View Controller

Our ServerServiceis 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!

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    var projects: [Project] = []

    let serverService = ServerService()

    override func viewDidLoad() {

        super.viewDidLoad()

        tableView.delegate = self

        tableView.dataSource = self

        loadData()

    }

    func loadData() {

        serverService.fetchProjects { [weak self] projects in

            guard let self = self else { return }

            self.projects = projects

            self.tableView.reloadData()

        }

    }

}

extension ViewController: UITableViewDataSource, UITableViewDelegate {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

        return projects.count

    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(

            withIdentifier: "ExpenseDetailTableViewCell",

            for: indexPath

        ) as! ExpenseDetailTableViewCell

        let project = projects[indexPath.row]

        cell.projectNameLabel.text = project.name

        cell.budgetLabel.text = project.budget

        if project.budgetValue >= project.totalExpenses {

            cell.budgetLabel.textColor = UIColor.green

        } else {

            cell.budgetLabel.textColor = UIColor.red

        }

        if let startDate = project.startDate {

            let dateFormatter = DateFormatter()

            dateFormatter.dateStyle = .medium

            dateFormatter.timeStyle = .none

            cell.startDateLabel.text = dateFormatter.string(from: startDate)

        }

        cell.statusLabel.text = project.status?.rawValue

        return cell

    }

}

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:

iOS Mobile Testing App Demo

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:

iOS Mobile Testing App Demo

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

iOS Mobile Testing App Demo

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.

iOS Mobile Testing App Demo

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

iOS Mobile Testing App Demo

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

  1. The number showing on the right side indicates how many times that line has been executed while running the tests. Here we can see that the budgetValue ran twice.
  2. The red and white dashed line in the right margin shows which lines were not tested.
  3. The red text shows the specific code that did not run in the tests.

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.

func testProjectEmptyBudget() {

    let data = dataFrom(filename: "project")

    let decoder = JSONDecoder()

    decoder.dateDecodingStrategy = .iso8601

    let project = try! decoder.decode(Project.self, from: data)

    project.budget = nil

    XCTAssertEqual(project.budgetValue, 0)

}

func testProjectWrongBudget() {

    let data = dataFrom(filename: "project")

    let decoder = JSONDecoder()

    decoder.dateDecodingStrategy = .iso8601

    let project = try! decoder.decode(Project.self, from: data)

    project.budget = "this is unexpected value"

    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:

iOS Mobile Testing App Demo

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.

Conclusion

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

  • We went from a completely untestable project, with all the app logic in the ViewController, to a modular and easily testable app.
  • We looked at the cost/value aspects of testing to avoid over-zealous testing approaches.
  • Finally, we explained why you should write code with testing in mind, as this will help you write more modular code – even if you end up not testing it!

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!

Are you looking to hire Mobile Developers? Every Scalable Path developer has been carefully hand picked by our technical recruitment team. Contact us and we’ll have your team up and running in no time.