Getting started with unit testing your Swift code on iOS – part 1

Published on: September 30, 2019

Recently, I ran a poll on Twitter and discovered that a lot of people are not sure how to get started writing tests, or they struggle to get time approved to write tests for their code. In this blogpost, I will take you through some of the first steps you can take to start writing tests of your own and help you pave the way to a more stable codebase.

Why bother with tests at all?

You might be wondering why you should bother with code that tests your code. When you put it like that, the idea might indeed sound silly. To others, tests sound so horribly complex that writing them can hardly be easier and more stable than just writing code and testing your app by hand.

These are valid thoughts, but think of the last time you had to test something like a preferences form where you needed to make sure that every input you throw at it produces the expected output. Especially if the form contains something like email validation, testing the form can be time-consuming because there are so many valid and invalid inputs. After executing a couple of manual tests, you’re probably confident that everything works as expected and you stop testing it altogether until a user finds a bug in your logic.

What if I told you that an automated test can do the exact thing that takes you several minutes to check in less than a second. That’s pretty amazing, right? Once a test is written, it is a part of your test suite forever. This means that any time you run the test suite, you’re not only testing the feature you’re working on; you’re testing every feature you’ve ever written. All in the time it takes you to grab a cup of coffee.

The confidence that you can gain from having a test suite is liberating. And the fact that you simply know that nothing broke and everything works pretty much exactly as planned (we all forget test cases from time to time), ensures that every time you commit your code, you do so with confidence.

The objective of this blog post

In this two-part blog post, you’re going to build a very simple login form. You won’t actually build the form itself, because all logic for the form will be encapsulated in a ViewModel that you will write tests for. Don’t worry if you don’t know MVVM, never used it, or simply don’t like it. The point of this post isn’t to convince you that MVVM is the one architecture to rule them all (it isn’t) but simply to have a somewhat real-world scenario to write tests for.

By the end of this post, you should have some understanding of how you can structure your code and tests. You should also be able to adapt this to virtually any other code base that you come across. Before we get to writing the tests, let’s define a couple of basic acceptance criteria that we’re going to write tests for in this post.

  • When both login fields are empty, pressing the login button should trigger an error that informs the user that both fields are mandatory.
  • When one of the two fields is empty, pressing the login button should trigger an error that informs the user that the empty field is mandatory.
  • When the user’s email address does not contain an @ symbol, pressing the login button should trigger an error that informs the user that they must provide a valid email address.

The requirements above are somewhat incomplete, but they should reflect a sensible set of tests that you might want to write for a login form. If you want to follow along with all code written in this post and the next, now is the time to launch Xcode and create a new project. Make sure you check the “Include Unit Tests” checkbox when creating the project. Since SwiftUI is still brand new and not everybody is able to use it in their projects, we’ll use a Storyboard based template for now. If you accidentally chose SwiftUI, that’s okay too, we’re not building UI in this post.

Writing your first test

If you’ve created your project with the “Include Unit Tests” checkbox enabled, you’ll find a folder that had the Tests suffix in the Project Navigator. This folder belongs to your test suite and it’s where you should add all your unit test files. The default file contains a couple of methods that are important to understand. Also note that there is a special import declaration near the top of the file. It has the @testable prefix and imports your app target. By importing your app or framework with the @testable prefix, you have access to all public and internal code that’s in your app or framework.

The setUp and tearDown methods

If your test code contains shared setup logic, you can use the setUp method to perform work that must be performed for every test. In our tests, we’ll use the setUp method to create an instance of the LoginFormViewModel. Similar to the setUp method, the tearDown method can be used to release or reset certain resources that you may have created for one test, but don’t want sticking around for the next test.

The test methods

In addition to setUp and tearDown, the default template Xcode provides contains two methods that have the test prefix. These test methods are your actual tests. Any method in an XCTectCase subclass with the test prefix is considered a test by XCTest and will be executed as part of your test suite.

The testPerformanceExample shows a very basic setup for performance testing. This is useful if you want to make sure that a complex algorithm you’re working on doesn’t become much slower in future iterations of your work. We’ll ignore that method for now, since we won’t be doing any performance testing in this post. Go ahead and remove testPerformanceExample from the file.

Now that you have an idea of what a test file contains, let’s rename the testExample to testEmptyLoginFormThrowsError and learn how to implement your first test!

Getting to work

Since you already have the test file open, let’s write a test for the login form first. After that, we can start implementing the form. First, add a property for the login form to the test class:

var loginFormViewModel: LoginFormViewModel!

As mentioned before, the setUp method should be used to create a new instance of the login form view model for every test case. Add the following implementation for setUp to create the view model:

override func setUp() {
  loginFormViewModel = LoginFormViewModel()

Your code doesn’t compile at this point. This is expected since the LoginFormViewModel is not defined yet. Writing tests before you write an implementation is common practice in Test Driven Development. You’ll create an implementation for LoginFormViewModel right after implementing your first test.

Every test we write will now have access to a fresh instance of LoginFormViewModel. The first test we’re going to write makes sure that when both the username and the password on the login form are empty and that we receive a specific error whenever we attempt to login. XCTest uses assertions to ensure that certain values are exactly what they should be. To check if something is nil for example, you use the XCTAssertNil method. Add the following two lines of code to testEmptyLoginFormThrowsError to assert that both username and password are nil:


If either the username or password property is true, XCTest will mark that assertion and the test itself as failed. XCTAssertNil is a fairly simple assertion. Let’s look at a somewhat more complex assertion to check whether login in throws an appropriate error:

XCTAssertThrowsError(try loginFormViewModel.login()) { error in
  guard case let LoginError.formIsEmpty = error else {
    XCTFail("Expected the thrown error to be formIsEmpty")

The code above asserts that loginFormViewModel.login() throws an error. The XCTAssertThrowsError method accepts a closure that can be used to inspect the error that was thrown. In this case, we want to make sure that the thrown error is the expected error. If we receive the wrong error, we fail the test by calling XCTFail with a failure message. If your test fails due to login throwing an unexpected error, Xcode will show the message you provided to XCTFail in the console to provide some guidance in figuring out exactly why your test failed.

Now that you have an entire test prepared, let’s write the code that needs to be tested! Select your app’s folder in the file navigator and create a new Swift file called LoginFormViewModel. Define a new LoginFormViewModel struct and give it a username and password property that are both defined as var and of type String?. Also, define a login method and mark it as throwing by adding throws after the method signature.

Since your test expects a specific error to be thrown, define a new enum outside of the LoginFormViewModel and name it LoginError. Make sure it conforms to Error and add a single case to the enum called formIsEmpty. After creating the enum, add the following line of code to your login() method:

throw LoginError.formIsEmpty

This might seem strange to you. After all, shouldn’t login() only throw this error if both the username and password are nil? And you’d be absolutely correct. However, since we’re writing the test suite using an approach that is loosely based on Test Driven Development, we should only be writing code to make tests pass. And since we’ve only written a single test, the current implementation is correct based on the test suite. By working like this, you can be sure that you’re always testing all the code paths you want to test. Later in this blog post, you’ll refactor the login() method to do nil checking and more.

At this point, you should have the following contents in your LoginFormViewModel.swift file:

import Foundation

enum LoginError: Error {
  case formIsEmpty

struct LoginFormViewModel {
  var username: String?
  var password: String?

  func login() throws {
    throw LoginError.formIsEmpty

Go back to your test file and run your tests by pressing cmd + u. Your project and test suite should build, and after a couple of seconds, you should get a notification saying that your tests passed. Note the green checkmarks next to your test class and the testEmptyLoginFormThrowsError method.

Congratulations! You have written your first test. Now let’s switch into second gear and walk through the process of expanding the tests for your view model.

Completing the objective

You have currently implemented one out of three tests that are needed for us to consider the objective to be complete. Both of the remaining tests are very similar to the test you wrote earlier.

  • When one of the two fields is empty, pressing the login button should trigger an error that informs the user that the empty field is mandatory.
  • When the user’s email address does not contain an @ symbol, pressing the login button should trigger an error that informs the user that they must provide a valid email address.

Try to implement these on your own. If you get stuck you can either look back at the test you already wrote or have a look at the GitHub repository that contains the completed test suite and implementation for this blog post. The solution for requirements one through three are in the folder HelloUnitTests-PartOne.

Hint: one of the requirements is best tested with two separate test cases and you should use an assertion method that confirms something is not nil.

If you’ve managed to implement both tests without looking at the solution on GitHub, I tip my hat to you, well done! If you did have to sneak a peek, don’t worry. You completed the assignment and you have learned a couple of things about writing tests!

Concluding part 1

In this first part of Getting started with unit testing on iOS you have learned how to write some basic, synchronous tests. Try coming up with some more tests you can execute on the view model, or maybe attempt to create a registration view model that has a couple of more fields and validation rules.

In the next part of this blog post, you will apply some major refactors to the test suite you’ve built in this post to do some asynchronous testing. You’ll also learn how to create a testable abstraction for networking logic! Grab some coffee, take a breath and click right through to Getting started with unit testing on iOS - part 2.