Documentation/en-us/TestUsingTestDoubles.md
Dependencies between objects can cause problems when writing tests. For example, say you have a Car class that depends on/uses Tire.
CarTests tests Car, which calls Tire. Now bugs in Tire could cause CarTests to fail (even though Car is okay). It can be hard to answer the question: "What's broken?".
To avoid this problem, you can use a stand-in object for Tire in CarTests. In this case, we'll create a stand-in object for Tire called PerfectTire.
PerfectTire will have all of the same public functions and properties as Tire. However, the implementation of some or all of those functions and properties will differ.
Objects like PerfectTire are called "test doubles". Test doubles are used as "stand-in objects" for testing the functionality of related objects in isolation. There are several kinds of test doubles:
Let's start with how to use mock objects.
A mock object focuses on fully specifying the correct interaction with other objects and detecting when something goes awry. The mock object should know (in advance) the methods that should be called on it during the test and what values the mock object should return.
Mock objects are great because you can:
For example, let's create an app which retrieves data from the Internet:
ViewController.DataProviderProtocol, which specifies methods for fetching data.DataProviderProtocol is defined as follows:
protocol DataProviderProtocol: class {
func fetch(callback: @escaping (data: String) -> Void)
}
fetch() gets data from the Internet and returns it using a callback closure.
Here is the DataProvider class, which conforms to the DataProviderProtocol protocol.
class DataProvider: NSObject, DataProviderProtocol {
func fetch(callback: @escaping (data: String) -> Void) {
let url = URL(string: "http://example.com/")!
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) {
(data, resp, err) in
let string = String(data: data!, encoding: .utf8)
callback(data: string)
}
task.resume()
}
}
In our scenario, fetch() is called in the viewDidLoad() method of ViewController.
class ViewController: UIViewController {
// MARK: Properties
@IBOutlet weak var resultLabel: UILabel!
private var dataProvider: DataProviderProtocol?
// MARK: View Controller Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
let dataProvider = dataProvider ?? DataProvider()
dataProvider.fetch({ [weak self] (data) -> Void in
self?.resultLabel.text = data
})
}
}
DataProviderProtocolViewController depends on DataProviderProtocol. In order to test the view controller in isolation, you can create a mock object which conforms to DataProviderProtocol.
class MockDataProvider: NSObject, DataProviderProtocol {
var fetchCalled = false
func fetch(callback: @escaping (data: String) -> Void) {
fetchCalled = true
callback(data: "foobar")
}
}
The fetchCalled property is set to true when fetch() is called, so that the test can confirm that it was called.
The following test verifies that when ViewController is loaded, the view controller calls dataProvider.fetch().
final class ViewControllerSpec: QuickSpec {
override class func spec() {
describe("view controller") {
it("fetches data with the data provider") {
let mockProvider = MockDataProvider()
let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("ViewController") as! ViewController
viewController.dataProvider = mockProvider
expect(mockProvider.fetchCalled).to(beFalse())
_ = viewController.view
expect(mockProvider.fetchCalled).to(beTrue())
}
}
}
}
What if DataProvider could fail and throw an error? This is easy to handle. The
easiest way to handle that is to have DataProviderProtocol.fetch(callback:)'s
callback take in a Result<String, Error>, instead of a String. Like so:
protocol DataProviderProtocol: class {
func fetch(callback: @escaping (Result<String, Error>) -> Void)
}
This is also commonly expressed by having the callback take in (data: String?, error: Error?)
arguments, which can express the same thing.
Of course, with the updated DataProviderProtocol, we have to update our DataProvider:
class DataProvider: NSObject, DataProviderProtocol {
func fetch(callback: @escaping (Result<String, Error>) -> Void) {
let url = URL(string: "http://example.com/")!
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) {
(data, resp, err) in
if let data {
let string = String(data: data, encoding: .utf8)!
callback(.success(string))
} else {
callback(.failure(err!)
}
}
task.resume()
}
}
Then, of course, our View Controller will need to be updated to deal with the
fact that DataProvider can return an error:
class ViewController: UIViewController {
// MARK: Properties
@IBOutlet weak var resultLabel: UILabel!
private var dataProvider: DataProviderProtocol?
// MARK: View Controller Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
let dataProvider = dataProvider ?? DataProvider()
dataProvider.fetch({ [weak self] (result) -> Void in
switch result {
case .success(let data):
self?.resultLabel.text = data
case .failure(let error):
self?.resultLabel.text = "Error, try again"
}
})
}
}
And then, we will update MockDataProvider. In this case, we will be changing
MockDataProvider so that it doesn't immediately call the callback. This allows
us to test the intermediate state in the code (before the callback has resolved),
as well as both the success and failure cases.
class MockDataProvider: NSObject, DataProviderProtocol {
var fetchCalled: Bool { !fetchCalls.isEmpty }
private(set) var fetchCalls = [(Result<String, Error>) -> Void) = []
func fetch(callback: @escaping (Result<String, Error>) -> Void) {
fetchCalls.append(callback)
}
}
In this case, we are explicitly storing the arguments that fetch(callback:) is
called with, and, as a convenience, changing fetchCalled from a stored
property to a computed property based on whether fetchCalls is empty or not.
Finally, we update our test code. Here, we're going to take advantage of Quick's tree-like structure to have our tests represent the branching paths our code takes. This allows us to re-use the same setup code (create the dependencies, load the view, resolve the callback) without having to repeat ourselves, or create setup functions that we could forget to call:
final class ViewControllerSpec: QuickSpec {
override class func spec() {
describe("view controller") {
var mockProvider: MockDataProvider!
var viewController: ViewController!
beforeEach {
mockProvider = MockDataProvider()
viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("ViewController") as! ViewController
viewController.dataProvider = mockProvider
}
it("doesn't immediately fetch the data from the data provider") {
expect(mockProvider.fetchCalled).to(beFalse())
}
context("when the view is loaded") {
beforeEach {
_ = viewController.view
}
it("fetches data with the data provider") {
expect(mockProvider.fetchCalled).to(beTrue())
}
context("when the callback returns valid data") {
beforeEach {
mockProvider.fetchCalls.last?(.success("hello"))
}
it("shows the data in the resultLabel") {
expect(viewController.resultLabel.text).to(equal("hello"))
}
}
context("when the callback returns an error") {
beforeEach {
mockProvider.fetchCalls.last?(.failure(NSError()))
}
it("let's the user know we had an error") {
expect(viewController.resultLabel.text).to(equal("Error, try again"))
}
}
}
}
}
}
This example also took care to restructure the earliest tests so that each behavior is tested in its own test, as described and encouraged in Behavioral Testing.
Swift Concurrency, or async/await, requires a slight change in how we mock the return value, as well as how we structure tests to handle this. This example assumes basic familiarity with Async/Await in Swift. If you are unfamiliar with Async/Await in Swift, please review Meet Swift Concurrency, from WWDC 2021.
Utilizing the previous example, what if we wanted to provide an async version of
DataProviderProtocol.fetch(callback:)? Assuming you're familiar with
Async/Await in swift, this is relatively easy to provide. Again, going with our
earlier example, we're going to start with the implementation code. First the
DataProviderProtocol:
protocol DataProviderProtocol: class {
func fetch() async throws -> String
}
This is a fairly straightforward conversion. Next, we'll update the actual
DataProvider, in this case to use the Async/Await API for URLSession:
class DataProvider: NSObject, DataProviderProtocol {
func fetch() async throws -> String {
let url = URL(string: "http://example.com/")!
let session = URLSession(configuration: .default)
let (data, _) = try await session.dataTask(with: url)
return String(data: data, encoding: .utf8)!
}
}
Third, we'll update the ViewController to use this new Async version of fetch():
class ViewController: UIViewController {
// MARK: Properties
@IBOutlet weak var resultLabel: UILabel!
private var dataProvider: DataProviderProtocol?
private var fetchTask: Task<Void, Never>?
deinit {
fetchTask?.cancel()
}
// MARK: View Controller Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
fetchTask = Task { [weak self] in
await self?.fetchData()
}
}
@MainActor
func fetchData() async {
let dataProvider = dataProvider ?? DataProvider()
let labelString: String
do {
labelString = try await dataProvider.fetch()
} catch {
labelString = "Error, try again"
}
resultLabel.text = labelString
}
}
This is slightly more complicated. Because viewDidLoad is not an async method,
we need to kick off a background Task to create the async context to invoke these
async methods in. This task then calls fetchData, which contains the bulk of
what used to be in viewDidLoad.
Additionally, because UILabel.text must be updated on the main thread, we
need to annotate the new fetchData method with @MainActor so that we set
resultLabel.text on the main thread.
Also, while not required, we are saving off the created background task, for the
explicit purpose of cancelling it when ViewController is deallocated.
With the implementation code updated, now it's time to update our Mock. This
is where things start to look different from what you'd expect. After much
research and experimentation, I (@younata, the
author) have determined that the best way to mock Async methods is to always
have them "preresolved". In contrast to earlier, where we stored the arguments
to fetch(callback:), and then called the callback at our leisure; I have come
to the conclusion that the Swift Concurrency runtime does not like to have
tasks kept waiting for an indeterminate amount of time. Every mechanism I have
come up with to force this results in ~50% of the tests randomly failing.
Instead, I encourage a paradigm where mocks always return a known value. This
paradigm requires a little extra infrastructure, which is not (currently) in
Quick or Nimble. Which we'll call AsyncResult. AsyncResult is an enum which
represents the 3 states an Async method can return: Success, Failure, and
Pending. Part of the infrastructure we'll add is that Pending will always throw
an error return after sleeping for a bit. This enables us to still test the
intermediate state before the async method has resolved, while satisfying
Swift's need to always return a value.
import Nimble
enum AsyncResult<Value, Failure: Error> {
case success(Value)
case failure(Failure)
case pending
func resolve(timeout: NimbleTimeInterval = PollingDefaults.pollInterval * 2) async throws -> Value {
switch self {
case .success(let value):
return value
case .failure(let error):
throw error
case .pending:
try await Task.sleep(for: timeout)
throw AsyncResultTimedOutError()
}
}
}
struct AsyncResultTimedOutError: Error {}
As you can see, in the success or failure cases, AsyncResult.resolve
immediately returns the associated value. In the pending case,
AsyncResult.resolve will wait for some period of time (defaulting to 2 *
however long Nimble would poll for when using a toEventually-style matcher).
This value feels like a good-enough wait time.
As-is, this mock only works with async throws methods. For mocking
non-throwing methods, the following extension which utilizes a fallback will work:
extension AsyncResult where Failure == Never {
func resolve(fallback: Value, timeout: NimbleTimeInterval = PollingDefaults.pollInterval * 2) async -> Value {
do {
try await resolve(timeout: timeout)
} catch {
return fallback
}
}
}
With this infrastructure, we will update our MockDataProvider to use it:
class MockDataProvider: NSObject, DataProviderProtocol {
private(set) var fetchCalled: Bool = false
private(set) var fetchStub = AsyncResult<String, Error>.pending
func fetch() async throws -> String {
fetchCalled = true
return fetchStub.resolve(fallback: .failure(NSError()))
}
}
Next up, because we preresolve our mocks, we have to adjust our test structure
such that fetch() is called after we change the value of fetchStub. In our
case, this means that we want to make sure that ViewController.viewDidLoad is
called immediately before each of the test called. Which we'll do using the
justBeforeEach method in the Quick DSL. justBeforeEach works to add the
associated closure at the end of the set of beforeEach closures. Additionally,
because we'll be running background tasks, we'll need to use Nimble's polling
matchers to test that the resultLabel will be updated eventually.
Lastly, because this test isn't directly invoking any async calls, we do not to
make this an AsyncSpec. This test will still run as a traditional QuickSpec.
final class ViewControllerSpec: QuickSpec {
override class func spec() {
describe("view controller") {
var mockProvider: MockDataProvider!
var viewController: ViewController!
beforeEach {
mockProvider = MockDataProvider()
viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("ViewController") as! ViewController
viewController.dataProvider = mockProvider
}
it("doesn't immediately fetch the data from the data provider") {
expect(mockProvider.fetchCalled).to(beFalse())
}
context("when the view is loaded") {
justBeforeEach {
// if this were a regular beforeEach, MockDataProvider.fetch() would get called much too early
_ = viewController.view
}
it("fetches data with the data provider") {
expect(mockProvider.fetchCalled).toEventually(beTrue())
}
context("when the callback returns valid data") {
beforeEach {
mockProvider.fetchStub = .success("hello")
}
it("shows the data in the resultLabel") {
expect(viewController.resultLabel.text).toEventually(equal("hello"))
}
}
context("when the callback returns an error") {
beforeEach {
mockProvider.fetchStub = .failure(NSError())
}
it("let's the user know we had an error") {
// because AsyncResult has a default timeout of 2 * Nimble's polling interval,
// this test would fail if we had forgotten to reset mockProvider.fetchStub.
expect(viewController.resultLabel.text).toEventually(equal("Error, try again"))
}
}
}
}
}
}
If you're interested in learning more about writing tests, continue on to https://realm.io/news/testing-in-swift/.