DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

David van Erkelens
David van Erkelens

Posted on

Writing unit tests with mocked dependencies in Swift

When working on your code, no matter the language or framework you're working in, it's vital for the maintainability of your code to write tests. So, when I dove into the world of iOS app development a little over a year ago, I made sure to also figure out how to write tests for my code in Swift.

Having quite some experience with writing tests in .NET and PHP, I was familiar with the Moq library and Prophecy and how they're used to mock the dependencies of the class you're testing. I wanted something similar in Swift, so I set out to figure out how to do this.

The start

First of all, let's start with a simple example of the code we want to test. The following example contains a very simple data manager object (the class we want to test), that has a dependency on an API client.

struct UserInfo {
    let name: String
}

class DataManager {
    private let client = ApiClient()

    public func getUserInfo() -> UserInfo {
        let name = client.getUserName()
        return UserInfo(name: name)
    }
}

class ApiClient {
    // Imagine a call to an API begin performed here
    public func getUserName() -> String {
        "David"
    }
}
Enter fullscreen mode Exit fullscreen mode

If we want to test the DataManager class in this example, we'd be dependent on the ApiClient working as expected - that's not what we want, since we only want to test the behaviour of the DataManager class. So let's get to work, and refactor this example.

Inject that dependency

The first refactoring step we can take, is removing the construction of the dependency from the class. This way, it's up to the caller to provide the dependency to the DataManager, a practice known as Dependency Injection. This way, our DataManager class will look as follows:

class DataManager {
    private let client: ApiClient

    public init(client: ApiClient) {
        self.client = client
    }

    public func getUserInfo() -> UserInfo {
        let name = client.getUserName()
        return UserInfo(name: name)
    }
}
Enter fullscreen mode Exit fullscreen mode

While that removes the constructing of ApiClient from DataManager, it does not remove the dependency on this exact implementation - so let's continue our refactoring.

Introducing a protocol

Our next step is to make DataManager unaware of the exact implementation of ApiClient, since DataManager only has to know that the getUserName() function will return a String. To do this, we'll introduce a protocol for the ApiClient and refactor the DataManager to expect an instance of a class implementing that protocol.

class DataManager {
    private let client: ApiClientProtocol

    public init(client: ApiClientProtocol) {
        self.client = client
    }

    public func getUserInfo() -> UserInfo {
        let name = client.getUserName()
        return UserInfo(name: name)
    }
}

protocol ApiClientProtocol {
    func getUserName() -> String
}

class ApiClient: ApiClientProtocol {
    // Imagine a call to an API begin performed here
    public func getUserName() -> String {
        "David"
    }
}
Enter fullscreen mode Exit fullscreen mode

With this in place, we can create multiple different classes implementing the ApiClientProtocol and pass those to the DataManager, allowing us to replace the actual ApiClient from our application code with a special test version in our test code.

Writing a test

With this setup in place, let's look at a simple test case for our DataManager.

final class DataManagerTests: XCTestCase {
    let apiClient = FakeApiClient()
    lazy var dataManager = DataManager(client: apiClient)

    func testGetUserInfo_ReturnsUserInformation() {
        var result = dataManager.getUserInfo()

        XCTAssertEqual("my-test-name", result.name)
    }
}

class FakeApiClient: ApiClientProtocol {
    func getUserName() -> String {
        "my-test-name"
    }
}
Enter fullscreen mode Exit fullscreen mode

By having an instance of a class implementing the ApiClientProtocol injected into the DataManager, we can easily create a special class just for tests to fake the behaviour of the actual ApiClient, and test the behaviour of DataManager.

This is already very nice, but still pretty limited - without a lot of extra code, we can not easily set different return values, have the function throw, or validate if the function has actually been called. And with more and more functions added to the protocol, each and every implementation of the protocol that you write for your tests has to be extended - not really a maintainable solution. Luckily, there are packages that can help us out here.

Set up the mocking library

Instead of creating our own test version of a class implementing the ApiClientProtocol, we'll use a mocking package to do the heavy lifting for us. There are multiple mocking packages for Swift, but I'll be using MockSwift here. Setting it up requires some work, since it has to work around the limitations of Swift.

To set up MockSwift, make sure you have Sourcery and the package installed. Then, create a script called gen-mocks.sh with the following content (updating the paths if required):

sourcery \
--sources . \
--templates Tests/MockSwift.stencil \
--output Tests/GeneratedMocks.swift \
--args module=<YOUR MODULE>
Enter fullscreen mode Exit fullscreen mode

To make sure that our protocol can be mocked, we need to mark it as mockable. We can do so by adding an annotation to our protocol:

// sourcery: AutoMockable
protocol ApiClientProtocol {
    func getUserName() -> String
}
Enter fullscreen mode Exit fullscreen mode

If we now run the gen-mocks.sh script, the generated file GeneratedMocks.swift will contain all the code we need to create mocks in our tests - so let's do so!

Let's mock it

First, we'll remove the FakeApiClient from our test file, and import the MockSwift package. Then, we'll update the definition of the apiClient variable with the @Mock annotation, and we'll replace the assignment with a typehint:

@Mock var apiClient: ApiClientProtocol
Enter fullscreen mode Exit fullscreen mode

That's all the code needed to create a mock of our protocol - now we just need to define the behaviour of the mock when a certain function is called. This, we can do as follows:

given(apiClient).getUserName().willReturn("my-test-name")
Enter fullscreen mode Exit fullscreen mode

This will make our mock behave in the same way as our old FakeApiClient class did - but with a lot of advantages. We can now also validate that the DataManager indeed called the function as expected:

then(apiClient).getUserName().called(times: 1)
Enter fullscreen mode Exit fullscreen mode

Working with this mocking library also allows different return values of the function, or even having the function throw, all to reliably test the behaviour of the class you're actually testing. Our test case finally looks like this:

import XCTest
import MockSwift

final class DataManagerTests: XCTestCase {
    @Mock var apiClient: ApiClientProtocol
    lazy var dataManager = DataManager(client: apiClient)

    func testGetUserInfo_ReturnsUserInformation() {
        given(apiClient).getUserName().willReturn("my-test-name")

        var result = dataManager.getUserInfo()

        then(apiClient).getUserName().called(times: 1)
        XCTAssertEqual("my-test-name", result.name)
    }
}
Enter fullscreen mode Exit fullscreen mode

Using mocks in your tests allows you to reliably unit test your classes, without worrying about its dependencies. In Swift, it's quite a bit of work to set it up - but when you've done so, writing tests become a breeze.

Happy testing!

Top comments (0)

You’ve already scrolled down this far, why not join our community of 900k+ developers all learning together?