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"
}
}
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)
}
}
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"
}
}
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"
}
}
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>
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
}
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
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")
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)
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)
}
}
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)