Mocking in Swift?
Today I’ll talk about a technique I came up with that I’ve been using for the past few years with regards to testing and mocking in Swift. I call it the Swift Enum Mock Pattern or EMP for short (patent pending 🙃).
First, to set the level, let’s ask a few questions.
What is a mock?
A mock object is described as a:
… simulated object that mimics the behavior of a real object in controller ways, most often as part of a software testing initiative
For example, rather than having to depend on an HttpClient
class’s methods for testing which rely on making REAL http calls out to the wild, wild web (WWW) – which are out of our control – a mock object (MockHttpClient
) would be able to simulate a real http call and give us controlled results in its place.
Migrating an existing class for test
Extract and implement an interface (protocol
)
Let’s look at our existing sample NetworkClient
:
enum NetworkClient {
/// Reusable URLSession for making requests
private static let session: URLSession = {
URLSession(configuration: .default)
}()
typealias Response<T: Decodable> = (T?, Error?) -> Void
static func fetch<T: Decodable>(urlString: String, completion: @escaping Response<T>) {
// performs real code to fetch data and deserialize from web service
}
// ...
}
Note: Implementation details are left out here for brevity.
Luckily swift protocol
s support static
methods. First, we extract our public interface into its own protocol
:
// 1. Create new protocol
protocol Networkable {
// 2. hoist our Response typealias up since it's coupled to our `fetch` method
typealias Response<T: Decodable> = (T?, Error?) -> Void
// 3. Extract `fetch(urlString:completion:)`
static func fetch<T: Decodable>(urlString: String, completion: @escaping Response<T>)
}
Make NetworkClient
conform to Networkable
:
enum NetworkClient: Networkable {
// ...
}
Inject protocol
Then where we would normally use NetworkClient
:
enum DogPhotoClient {
static let dogUrl = "https://dog.ceo/api/breeds/image/random"
/// Requests random dog photos from dog photo service
static func fetchDogPhoto(completion: @escaping (DogPhoto?, Error?) -> Void) {
// 1. this is where we use `NetworkClient`
NetworkClient.fetch(urlString: Self.dogUrl,
completion: completion)
}
// ...
}
We would inject a Networkable
, store it as a field and use that instead.
🤔 But enums can’t store fields, so we’ll have to convert it to a class first, and update our call sites:
// 1. enum becomes a class
final class DogPhotoClient {
private static let dogUrl = "https://dog.ceo/api/breeds/image/random"
// 2. create a standard drop-in replacement for our average case scenario, this is what our consumers will use 99% of the time.
static let standard = DogPhotoClient(client: NetworkClient.self)
// 3. add `client` field to store Networkable
// 4. Since `NetworkClient` is a non-instantiable object (enum), we use Networkable.Type to inject the type rather than an instance of it.
private let client: Networkable.Type
init(client: Networkable.Type) {
self.client = client
}
/// Requests random dog photos from dog photo service
func fetchDogPhoto(completion: @escaping (DogPhoto?, Error?) -> Void) {
// 5. Instead of calling `NetworkClient.fetch` we use our `client` field
self.client.fetch(urlString: Self.dogUrl,
completion: completion)
}
// ...
}
Update call sites
This breaking change will force us to update our call sites:
// 1. note the newly added `.standard`:
DogPhotoClient.standard.fetchDogPhoto { dogPhoto, error in
print(">> Dog Photo: \(String(describing: dogPhoto))")
dogPhoto?.write(toJson: File.Sample.dogFile)
}
Note: If we wanted this code to also be testable we would factor out our DogPhotoClient.standard
to its own field that could also be injected in our test classes. For brevity we’ll focus just on testing our DogPhotoClient
class.
Our DogPhotoClient
is now testable.
Add unit tests
We can now add unit tests. Under our project’s Tests
directory we’ll
create a similar structure to our production code and add these folders to
Xcode. I find the simplest way to create a nested directory structure is
through the command line using mkdir
:
mkdir -p Core/Networking/DogPhoto
Create a CallStack
helper class
The CallStack
class is what powers our EMP under the hood. It’s a data
structure that allows us to keep track of a list of calls to our mock object.
The name call stack is admittedly a bit of a misnomer due to the fact that:
- It is not a stack - we have random access and do not implement any stack-like behavior
- It tracks calls made to our mock — not individual frames for a single method call
Create Tests/Core/CallStack.swift
:
// 1. Create a CallStack class using a generic `Method` type to represent the methods we call
final class CallStack<Method: Equatable> {
// 2. Create a list to track mock method calls
private var callStack = [Method]()
// 3. Expose a method to check our list of calls for a match and make an assertion
func assertCallWasMade(
to method: Method,
file: StaticString = #file,
line: UInt = #line) {
XCTAssertTrue(
callStack.contains(method),
"Expected call to \(method), but no such call was made.\n",
file: file,
line: line)
}
// 4. Add a `call` method to track calls made on our mock object
func call(method: Method) {
callStack.append(method)
}
}
Create a MockNetworkClient
class
The class we’ll be mocking is our NetworkClient
which our DogPhotoClient
uses to make network calls. Things get funky since our NetworkClient
is
fully static, so we’ll have to add some static storage and a method to reset
the state of our object.
Create Tests/Core/Networking/MockNetworkClient.swift
:
// 1. Mock network client implements Networkable protocol
enum MockNetworkClient: Networkable {
// 2. Track calls made to this object using a new `CallStack` structure (defined below)
private static var callStack = CallStack<Method>()
/// Resets the callStack.
/// Call this in the `setUp` method of your test file to reset call stack state
/// on every test case.
static func setUp() {
callStack = CallStack()
}
static func fetch<T>(
urlString: String,
completion: @escaping Response<T>) where T : Decodable {
callStack.call(method: .fetch(urlString: urlString))
}
// 3. An enum to match this method's testable apis
enum Method: Equatable {
// 4. Add a case for each method we want to test
// 5. Provide parameters as associated types if needed for testing
case fetch(urlString: String)
}
// 6. Following the law of demeter, forward our method calls to hide implementation details
static func assertCallWasMade(
to method: Method,
file: StaticString = #file,
line: UInt = #line) {
callStack.assertCallWasMade(to: method, file: file, line: line)
}
}
On the outside our MockNetworkClient
functions like our regular one,
conforming to the same Networkable
protocol. We provide a Method
enum
which has cases for every method in this class that we want to test. Following
the Law of Demeter, we forward our method calls to the call stack
while hiding it. This kind of information hiding or encapsulation allows us
to swap out the implementation of this method later and can help migrate us to
a different underlying CallStack
-like data structure or utility in the
future by keeping our code loosely coupled.
Test our subject
Now that all of our dependencies are set up, let’s create our Tests/Core/Networking/DogPhoto/DogPhotoClientTests
class:
import Foundation
import XCTest
@testable import SurvivalSwift
// 1. Create new class using XCTestCase
final class DogPhotoClientTests: XCTestCase {
// 2. The object we'll be testing in this class
private var sutDogClient: DogPhotoClient!
// 3. The real (expected) dogUrl
private static let dogUrl = "https://dog.ceo/api/breeds/image/random"
override func setUp() {
// 4. Reset mock network object (since it's static and we can't just make a new one)
MockNetworkClient.setUp()
// 5. Create new test object for a clean state
sutDogClient = DogPhotoClient(client: MockNetworkClient.self)
}
// 6. create a test method
func test_fetchDogPhoto_callsFetchApi_withProperUrl() {
// 7. call our test object's method
sutDogClient.fetchDogPhoto { photo, error in
// ignore result for this test
}
// 8. check mock object for call -- first using a bad string to make sure the test fails
MockNetworkClient.assertCallWasMade(to: .fetch(urlString: "test failed"))
}
}
Run the tests, and the tests should fail! After the test fails, replace "test
failed"
with Self.dogUrl
and the tests should turn green. Allowing the test
to fail first helps prove that our tests are actually doing something and not
just passing even when they shouldn’t.
Printing the CallStack
object
If we want to go the extra mile we can make some changes to allow our call stack to be printable.
// 1. Add `CustomDebugStringConvertible` to the `Method` conformance requirements
final class CallStack<Method: Equatable & CustomDebugStringConvertible> {
// ...
// 2. Create new `callStackString` computed property
private var callStackString: String {
callStack.reduce("") { partialResult, next in
partialResult + "\n\t -> " + next.debugDescription
}
}
// 3. Update `assertCallWasMade()` method to print callStack:
func assertCallWasMade(
to method: Method,
file: StaticString = #file,
line: UInt = #line) {
XCTAssertTrue(
callStack.contains(method),
"Expected call to \(method), but no such call was made.\n"
+ "Call stack:" + callStackString,
file: file,
line: line)
}
// ...
}
Then update our MockNetworkClient.Method
enum to conform to CustomDebugStringConvertible
:
// 1. Add CustomDebugStringConvertible conformance
enum Method: Equatable, CustomDebugStringConvertible {
case fetch(urlString: String)
// 2. Implement `debugDescription`
var debugDescription: String {
switch self {
case .fetch(let urlString):
return ".fetch(urlString: \"\(urlString)\")"
}
}
}
Change our Self.dogUrl
in DogPhotoClientTests
to "failed test"
again to make the test red and run:
DogPhotoClientTests.swift:28: error: -[SurvivalSwiftTests.DogPhotoClientTests test_fetchDogPhoto_callsFetchApi] : XCTAssertTrue failed - Expected call to .fetch(urlString: "failed"), but no such call was made.
Call stack:
-> .fetch(urlString: "https://dog.ceo/api/breeds/image/random")
The down side to adding debugDescription
is that it makes a little more effort to create these enums.