/ IOS

Real-World Unit Testing in Swift

Testing is about getting feedback on software. This is what gives us confidence in the program that we write. This article is an introduction to Swift unit testing. Let’s learn why to test, what to test, how to test and implement several real-world Swift unit tests.

What is Unit Test

Unit test is a method that runs piece of code to verify that a known, fixed input produces a known, fixed output [1]. If the assumptions turn out to be wrong, the unit test considered failed. What turns a test into a unit test is that the system under test is a very small piece of the overall program.

When you test something, you refer to it as the system under test (SUT).

Unit test verifies a single noticeable end result that can be observed in the system through the public APIs and behavior [2]. It can be:

  • Method return value.
  • Change of a property in a Swift class.
  • CRUD (create, read, update, delete) in CoreData or UserDefaults.
  • Firing a notification.

Why to Write Unit Tests

Your first thought might be to skip unit testing, since customer is not directly paying for them. However, Economics of Test Automation demonstrates that the effort spent on tests pays off a lot. The extra cost of developing and maintaining test suite is offset by savings on manual testing, debugging, regression testing, improved code structure.

We are writing unit tests, because we want our code to work and to keep it working [1].

The sole presence of a test suite is not enough for a good return on investment. There is no point in writing poor unit tests, unless you want to learn how to write the good ones. Through the rest of article let’s focus on what makes a good unit test and sharpen this knowledge by writing a couple of tests.

Defining Good Unit Test

A good unit tests possesses following qualities [2]:

  • Readable
  • Maintainable
  • Trustworthy

Let’s study each of them in detail.

Readable

Unit tests are intended to be read very often. When something breaks in your production code, you typically examine failing tests, which is going to happen quite frequently. When unit test fails, you should be able to understand the exact failure reason just by looking at the code. If you can’t understand what is going on, the test is likely to be rewritten or even removed.

Same as the production code, your tests should follow a single coding convention. It includes formatting, naming, preferred patterns and much more. I’ve touched on the subject in Missing Guide on Swift Code Style, where you’ll also find the list of most popular Swift code styles.

Here are the factors which contribute to tests readability:

  • Naming unit tests and variables
  • Clear assertion messages
  • Following Arrange-Act-Assert structure. More on this few paragraphs below.

Maintainable

When the new features are added to our production code, it should not result in a cascade of changes in the tests. Ideally, we should modify our tests only when the non-functional requirements change. Otherwise the tests can ruin project deliveries and are candidates to be disabled when schedule becomes aggressive.

Here is what makes unit tests maintainable:

  • Test only publics and internals. Do not weaken encapsulation just to verify private and fileprivate properties or methods.
  • Do not put code into production which is there only to support testing.
  • Verify one thing per test.
  • Reuse code. If tests contain lots of duplication, it’s time to introduce new abstractions. Create and share helper methods for initializations and verifications.

Trustworthy

A test is trustworthy when it makes clear what’s going on and that you can do something about it. When it passes, you trust such test and that the code works under this scenario. And when the test fails, you don’t tell yourself that this does not mean that the code is not working [2].

Here is what makes unit tests trustworthy:

  • 100% pass rate. Having at least one failing test results in a broken window effect.
  • Tests must be reproducible. Any flakiness should not be tolerated. All errors, crashes, freezes and failures that seem to happen ‘randomly’ will sooner or later infect whole test suite. I advert to the topic in Unit Testing Asynchronous Code in Swift.
  • Avoid test logic: no for or while loops, not if, else or switch statements. Not even a do-catch.

Writing First Unit Test

Let’s begin with testing a struct that validates username for a login screen:

struct UsernameValidator {
    func isValid(_ username: String) -> Bool {
        return username.count > 4
    }
}

How should we approach testing isValid method? Each unit test performs three main actions:

  1. Arrange objects, setting up the system under test as necessary.
  2. Act on an object. Here we usually call a method we want to test.
  3. Assert that the result of the action is as expected.

Arrange-Act-Assert is the primary pattern to structure unit tests. It is common in software development in general and not restricted to Swift.

Given-When-Then is another name for the same pattern. Each action from Given-When-Then directly maps to Arrange-Act-Assert.

Here is a test that reveals three actions:

class UsernameValidatorTests: XCTestCase {

    func testTooShortUsername() {
        let sut = UsernameValidator()
        let result = sut.isValid("U1")
        XCTAssertFalse(result)
    }
}

XCTAssert family of methods is located in XCTest.framework.

We might have written the same test in a single line:

func testTooShortUsername() {
    XCTAssertFalse(UsernameValidator().isValid("U1"))
}

Does it harm readability, since the Arrange-Act-Assert structure is not immediately obvious? In my opinion it does not, until we assert on a single concern per test.

Mocking in Swift

When we have a method that does not call anythings else, testing it is a piece of cake. Things become complicated, when a method has lots of dependencies, that cause uncontrollable changes. Image testing a Swift app from UIApplicationDelegate’s application(:, didFinishLaunchingWithOptions:).

To prevent side effects, we want to isolate the system under tests from all dependencies. Dependency injection is a technique of passing dependencies to an object.

I am touching on the subject in Dependency Injection in Swift.

In our test code we are using special kind of dependencies, called mocks, which mimic real objects for testing.

Say, our Swift app has login feature. We want to test user authentication with their username and password. Here is an implementation of AuthService:

class AuthService {
    func login(with username: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
        let request = URLRequest.login(username: username, password: password)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            // Handle response
        }.resume()
    }
}

Calling the login method has an uncontrollable effect: it executes a network request. Our unit test must isolate AuthService from network dependency. We can do this by the combination of dependency injection and mocking.

Firstly, let’s build an abstraction on top of networking and pass it as a dependency:

protocol HTTPClient {
    func execute(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void)
}

class AuthService {
    let httpClient: HTTPClient
    
    // Initialization code
    
    func login(with username: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
        let request = URLRequest.login(username: username, password: password)
        
        httpClient.execute(request: request) { result in
            // Handle response
        }
    }
}

Secondly, let’s mock HTTPClient for testing:

class HTTPClientMock: HTTPClient {
    var inputRequest: URLRequest?
    var executeCalled = false
    
    func execute(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
        executeCalled = true
        inputRequest = request
    }
}

The mock does not run any network requests, but only traces calls to execute method. In our test we want to verify that AuthService indeed calls execute. We also check that the request URL is the correct one.

class AuthServiceTests: XCTestCase {
    func testLogin() {
        let httpClient = HTTPClientMock()
        let sut = AuthService(httpClient: httpClient)
        
        sut.login(with: "U1", password: "P1") { _ in }
        
        XCTAssertTrue(httpClient.executeCalled)
        XCTAssertEqual(httpClient.inputRequest?.url, .login)
    }
}

💡 Tip: With the help of Sourcery mocks can be auto-generated.

Summary

The quality of unit tests code is equally important as that of the production code. Best practices suggest that your tests should be:

  • Readable
  • Maintainable
  • Trustworthy

We structure tests in Act-Arrange-Assert blocks to make them both readable and maintainable. Most of the discussed practices are not restricted to Swift and can be generally applied to the most of programming languages.

Further reading