Waldo sessions now support scripting! – Learn more
Testing

Getting Started with Unit Testing in Swift: A Guide

Juan Reyes
Juan Reyes
Getting Started with Unit Testing in Swift: A Guide
December 20, 2022
8
min read

If you've heard about unit testing, you know it's the preferred method of ensuring the quality and reliability of your work. And when used properly, a robust testing workflow is the pillar that sustains the team and even your reputation. But, for many developers, building a testing workflow is an extra effort that's not worth their time.

Sadly, many developers choose to forgo developing a test workflow because of laziness, lack of knowledge, or an unreasonably tight schedule. And although some of you might think you can get away with it, you will inevitably pay the price. Whether it's with increased technical debt, a bad reputation during deployments or long nights of working to fix some significant issue in your app that you overlooked, not relying on tests will cost you.

I think it's time we do something about it.

This article aims to familiarize you with the basic concepts of Swift unit testing and get you comfortable with its implementation. You should be able to answer questions like "What is unit testing?", "Why is unit testing important?", and "What are unit testing best practices?" by the end of this article. Additionally, you will have a testable sample project containing everything you need to be empowered to make your code fully testable and production ready.

This article is intended for Swift developers. However, if you are dipping your toes into Swift, I recommend you familiarize yourself with the language before continuing.

With that out of the way, let's get into it.

What Is Unit Testing?

Unit tests refer to a software testing strategy by which individual code units are tested and validated with a defined set of behaviors and expectations. These units of tests can include groups of one or more modules, dependencies, and services together with associated control data.

Let me illustrate.

Imagine you have a calculator app. Inside the app, many classes do various things, like handling user interactions, performing calculations, and maybe even making remote requests to a service.

To perform a unit test in this app, you would have to

  • isolate the logic inside an individual set of operations that make a logical group, and
  • perform validations based on certain assumptions.

That means that if we were, for example, tasked with testing that the calculating part of the app works reliably, we would have to work with the class containing all the logic that performs the mathematical operations.

For us Swift developers, XCode offers us the XCTest framework to design and perform our unit tests.

There are, of course, other approaches to app testing, such as UI testing, integration testing, performance testing, and coverage testing, to name a few.

Building a Unit Test Workflow in Swift

To aid you in understanding the intricacies of building tests in Swift, I have taken the liberty of creating a simple calculator app that will serve as the groundwork and playground for the exercises that follow in the article. Feel free to check out the project in my repository here and explore it to your heart's content.

Alright, now proceed to the SwiftTestingSampleTests folder in the project.

The code here comes by default when you create a project in XCode and is a great starting point to make your tests.

You have a method that allows you to set up the test workflow, a method to tear down and flush all resources after the test finishes, and an example test case you can use as a template.

Now, to create your first test workflow, we need to do some setup first.

Start by adding the following property to the SwiftTestingSampleTests.swift file.


   // Reference of the view controller that will be subjected to tests
   var sut: ViewController!   

This property will be a reference (System Under Test) to the view controller you will test.

Continue by changing the setupWithError() and the tearDownWithError() methods.


   override func setUpWithError() throws {
      // Put setup code here. This method is called before the invocation of each test method in the class.
      
      // Retrieve a reference of the main storyboard
      let storyboard = UIStoryboard(name: "Main", bundle: nil)
      // Initialize the view controller to be tested
      sut = storyboard.instantiateInitialViewController() as? ViewController
      // Load the view controller if needed
      sut.loadViewIfNeeded()
  }
  
override func tearDownWithError() throws {
      // Put teardown code here. This method is called after the invocation of each test method in the class.
      
      // Flush references
      sut = nil
  }

As you can see, the view controller is initialized during setup, and its views are loaded. Meanwhile, all references are flushed during teardown, clearing all resources and resetting the test state. This process is essential to ensure that all test cases are consistent, avoiding issues with the app state or global variables.

Adding a Test Case

Next, create a new test case and name it testResultsAreCalculatedProperly.


   func testResultsAreCalculatedProperly() throws {
   }

Notice that our test case starts with the word "test." This is intentional, as all test cases must begin with this word, or XCode won't recognize them.

Now, add the following code to this test case.


   func testResultsAreCalculatedProperly() throws {
		// Wait for 1 second
        sleep(1)
        // Retrieve and validate if the element exists and is setup
        let display = try XCTUnwrap(sut.display, "Display text field not properly setup")
        // Set initial values for the test
        display.text = "5+5"
        sut.numberArray = [5, 5]
        sut.operation = "+"
        // Wait for 1 second
        sleep(1)
        // Trigger operation
        sut.calculateResult()
        // Validate expectation
        XCTAssertEqual(display.text, "10", "Results are not being calculated properly!")
        // Wait for 1 second
        sleep(1)
    }

Here, we are retrieving the elements and setting values necessary to establish an initial consistent state. Then the operation to be tested is triggered to engage the logic with the set state. Finally, we assert our expectations and validate the output.

There are many different kinds of assertions at your disposal.


   XCTAssertNill / XCTAssertNotNil
   XCTAssertTrue / XCTAssertFalse
   XCTAssertThrowsError / XCTAssertNoThrow
   XCTAssertGreaterThan / XCTAssertLessThan
   XCTAssertIdentical / XCTAssertNotIdentical

All these follow a similar format and allow you to evaluate assumptions about the output of your operations.

You can find more on the official Apple documentation here.

This flow is the basis of all unit tests and can be expanded to any unit of work you might need.

Additionally, notice the calls to the sleep() method. These are here to make the test more visible to us, but they are unnecessary and would hinder the performance of large workflows of tests.

Unit Testing Best Practices

There are many ways to go about making your code testable and reliable. But not all ways are the best way. That's why following the best practices in unit testing is essential. Here are the most important practices to follow.

Your tests should be efficient.

It is imperative that your tests run as quickly as possible. This fact should become evident when your test workflow grows beyond just a few dozen tests and you hook it up to a continuous integration workflow. If your tests take seconds to run, that time will compound and take productive time away from your team.

Your tests should be readable and as simple as possible.

Just like any critical code in your project, you must make an effort to make your tests readable and digestible.

Test code is one of the most maintained code bases in projects, and when the complexity of projects increases, so does the temptation to make complex tests. You must avoid this because faulty tests will lead to poor quality and technical debt.

Your tests should not contain business logic.

When designing test cases for a specific workflow, it is tempting to include part of the business logic in the test case. This is a bad idea since it leads to a conflict of context and bad test designs where we end up injecting functionality into our tests instead of validating them.

Remember, the goal is to isolate the code we need to test and focus on providing the necessary input to achieve a consistent, expected outcome. Use stubs and mocks to set behavior expectations and validate the results.

Your tests should be deterministic.

This one is pretty self-explanatory. Your tests should produce the same outcome every time. Avoid dealing with states and resources that depend on user input or network. Don't generate input randomly; make sure to design your tests in a DRY way.

Why Is Automated Testing Important?

The main reason why testing is crucial is that it allows you to ensure that each operation behaves as it should and is not influenced or dependent on other factors. And if all functions behave as they should, then the whole project should too, unless the project itself is not well orchestrated. Then, hey, you found an issue that you must address anyway.

The objective of the unit test is to emulate the operation of the application in a controlled and granular way to determine if the application will respond as expected with valid input and handle invalid input graciously.

As we know, modern applications are designed to allow the user to perform operations and reach places in multiple ways. For example, you can get to the orders page on Amazon through your profile page, the settings, the history, or even a product page. This is, of course, intentionally designed to facilitate user interactions. However, each of these avenues contains intricacies that can affect the application's state, causing problems.

Conclusion

One of the most complicated aspects of a mobile project in terms of maintainability is testing. There's no denying that making sure your code is robust and functional is essential. But ensuring this is the case can be either a great asset to your developing workflow or a crippling hindrance if you don't know what you are doing. And if you choose not to do it at all, it can be even more harmful.

Attempting to do testing manually can be, let's say, cumbersome and time-consuming. This case is especially true for applications with a somewhat complex user flow and many screens. But here's the thing: it doesn't have to be this way.

If you find developing a testing workflow complicated, you're not alone. For many teams, the hours and effort necessary to build all this logic is just not a good investment of their limited resources.

For those teams, I recommend Waldo's extensive toolset for SwiftUI testing a check. It requires no coding and is very approachable, even for non-developers.

Automated E2E tests for your mobile app

Waldo provides the best-in-class runtime for all your mobile testing needs.
Get true E2E testing in minutes, not months.

Reproduce, capture, and share bugs fast!

Waldo Sessions helps mobile teams reproduce bugs, while compiling detailed bug reports in real time.