Unit testing is most effective when the unit tests are deterministic. And by deterministic means, the output is always the same for a specific input. No surprise, no randomness whatsoever. Not only in Swift but in all languages, test doubles like spy, stub, dummy, fake, and mock play crucial roles in making those tests deterministic. Without understanding the fundamental differences among them will create confusion in a team. In this blog post, we will be elaborating each test doubles with examples.
Usually, any component we want to unit test has one or more dependencies. To test an expected behavior, we need to fake some input somehow. Assume we need to store a user preference in some persistence store. For now, assume it will be User Defaults. We will use abstraction so the high-level business logic and low-level details do not depend on each other. Instead, they both depend on abstraction. Here, the logic to store the user preference is high-level logic. And the actual storing of User details is the low-level details.
The basics of dependency Inversion
We just mentioned both the higher-level logic and the low-level details should depend on abstraction. We have some details in our Introduction of Dependency Inversion on Swift talk. Also, in our Swift Dependency Inversion through Protocol talk, we share how to achieve Dependency Inversion in Swift. Finally, in the Dependency Injection in Swift talk, we demonstrated the details of dependency Injection. Feel free to visit those for a crystal idea of dependency inversion and injection.
Persistence store case study
We will use the persistence store storage as our case study for this blog post. Our goal is to store some user preferences in some persistence stores like User Defaults. Now, we will do TDD here. And by doing that, we will clarify the Test Doubles in Swift.
We will have three different user preferences. We will be storing those values in User Defaults.
public enum Preference {
case notNow
case never
case save(Credential)
}
Credential
is a simple struct
with username
and password
.
public struct Credential {
public let username: String
public let password: String
}
Let us start TDD for this case study and explore the Swift test Doubles like Spy, Stub, Dummy, Fake, and Mock.
Implementation through abstraction
Here is the abstraction, protocol, to separate the high-level business login from the low-level details.
protocol PreferenceStorable{
func save(_ data: Data, for key: String) -> Error?
}
We will have a controller who will be making the business decisions. We can call it StorageController
. StorageController
will have one instance of type PreferenceStorable
. Through this instance, the controller will save the preference. The User Defaults will confirm PreferenceStorable
and implement the low-level details (save
requirement).
Swift Test Doubles Code Implementation
On the TestDoubles GitHub repo, we have closed Pull requests demonstrating the usage of each test double. We can go through the pull request and select the next
or previous
button to see the evaluation of the code in TDD.
Spy
The very first thing we want to test is the initialization of the StorageController
does not initiate the saving. Then, we will confirm the save of StorageController
actually storing the preference. These two scenarios tell us we need to verify the saved value. That sounds like a Spy.
The concept of Spy is straightforward. Spy will only store values, nothing else. We will use this stored value of the Spy to validate the assumption through assertion. Here is an implementation of Spy. Note that return is a method-only requirement. The nil return does not have any relation with the Spy, SpyPreferenceStorage
.
class SpyPreferenceStorage: PreferenceStorable {
private(set) var preferences = [String: Data]()
func save(_ data: Data, for key: String) -> Error? {
preferences[key] = data
return nil
}
}
So, the required tests will be as follows.
class TestDoublesTests: XCTestCase {
func test_init_doesnotStorePreference() {
let (_, spy) = makeSUT()
XCTAssertTrue(spy.preferences.isEmpty)
}
func test_savePreference_storesPref() {
let (sut, spy) = makeSUT()
XCTAssertTrue(spy.preferences.isEmpty)
sut.save(preference: .never)
XCTAssertFalse(spy.preferences.isEmpty)
}
// MARK: - Helper
private func makeSUT() -> (sut: StorageController, spy: SpyPreferenceStorage) {
let spy = SpyPreferenceStorage()
let sut = StorageController(presistenceStore: spy)
return (sut, spy)
}
}
The resulting code for the SpyPreferenceStorage will be the following.
struct StorageController {
let presistenceStore: PreferenceStorable
func save(preference: Preference){
let encoder = JSONEncoder()
guard let data = try? encoder.encode(preference) else { return }
_ = presistenceStore.save(data, for: Self.PreferenceStoreKey)
}
}
extension StorageController{
static var PreferenceStoreKey: String {"PreferenceStoreKey"}
}
Stub
Now, once can confirm the value is stored now let us turn our attention to error handling. The StorageController
should be able to handle errors properly. If there is no error then the StorageController
should not propagate any error. And if an error occurs the StorageController
should propagate that exact error to the caller. So it seems like we need to mimic the erroneous state of the system to validate the error-handling behavior of the StorageController
. This is the job for Stub
.
Stub returns a predefined response. When we need one component to return some specific value we use a stub. Here is an example of Stub.
struct StubPreferenceStorage: PreferenceStorable {
let error: Error?
func save(_ data: Data, for key: String) -> Error? { error }
}
On our StubPreferenceStorage
stub we will set the error on init time. If we want some specific error then we will set that error on the init time. Otherwise, we can set the error to nil on init time to reflect a no error state of the system. Let us have a look at the test_save_throwsError_onErroneousStoring
test which simulates an erroneous state. On the other hand, the test_save_throwsNoError_onSuccessfulStoring
simulates no error state.
final class ResponseAfterStoringTests: XCTestCase {
func test_save_throwsError_onErroneousStoring() {
let expectedError = NSError(domain: "any error", code: -100)
let (sut, _) = makeSUT(expectedError)
do {
try sut.save(preference: .never)
XCTFail("Expected error but got none")
} catch {
XCTAssertEqual(error as NSError, expectedError)
}
}
func test_save_throwsNoError_onSuccessfulStoring() {
let noError: Error? = nil
let (sut, _) = makeSUT(with: noError)
do {
try sut.save(preference: .never)
} catch {
XCTFail("Expected no error but got an \(error)")
}
}
// MARK: - Helper
private func makeSUT(_ error: Error?) -> (sut: StorageController, stub: StubPreferenceStorage){
let stub = StubPreferenceStorage(error: error)
let sut = StorageController(presistenceStore: stub)
return (sut, stub)
}
}
Here we are setting the StubPreferenceStorage
with an NSError
. This error will be returned on the save(_ data: Data, for key: String)
call. Now let us update our production code StorageController
to return any error if occurs.
public struct StorageController {
private let presistenceStore: PreferenceStorable
public init(presistenceStore: PreferenceStorable) {
self.presistenceStore = presistenceStore
}
public func save(preference: Preference) throws{
let encoder = JSONEncoder()
guard let data = try? encoder.encode(preference) else { return }
if let error = presistenceStore.save(data, for: Self.PreferenceStoreKey) {
throw error
}
}
}
extension StorageController{
public static var PreferenceStoreKey: String {"PreferenceStoreKey"}
}
As you can see once we have an error in presistenceStore.save
we are throwing that error. So using stub we can define the presistenceStore
erroneous state which helps us to confirm the production code error handling behavior.
Dummy
Till now we were only concerned with the presistenceStore: PreferenceStorable
. But there is still another hidden dependency on the StorageController
. The JSONEncoder
that we are initiating on save
method. If some error occurs during the encoding time we have no way to return that error to the caller. So let us move our focus on that part of the code.
If we want to stub any error during the encoding process we need to inject this dependency, JSONEncoder
. So let us take an encoder on the init
method. As a result, the StorageController will look like the following.
public struct StorageController {
private let presistenceStore: PreferenceStorable
private let encoder: JSONEncoder
public init(presistenceStore: PreferenceStorable, encoder: JSONEncoder) {
self.presistenceStore = presistenceStore
self.encoder = encoder
}
public func save(preference: Preference) throws{
let data: Data
do{
data = try encoder.encode(preference)
}catch{
throw error
}
if let error = presistenceStore.save(data, for: Self.PreferenceStoreKey) {
throw error
}
}
}
Now returning the error state during the encoding process of the JSONEncoder
is a stub job. But what role the presistenceStore
now will take? The response of save
now does not matter. This sounds like a Dummy.
The Dummy is a filler. Dummy always returns a response, that does not have any impact on that specific test scenario. So here is a dummy look at the PreferenceStorable
.
class DummyPreferenceStorage: PreferenceStorable {
func save(_ data: Data, for key: String) -> Error? { nil }
}
On the save
method of the DummyPreferenceStorage
, we are not concerned about the return param. We just need a PreferenceStorable
to initiate the StorageController
like following.
let sut = StorageController(presistenceStore: DummyPreferenceStorage(),
encoder: StubFailableJsonEncoder(error: anyEncodingError))
The implementation of Dummy is visible on the corresponding pull request.
Fake
Among all test doubles, the Fake is different from its kind. Fake has its brain. So, a Fake will return results based on the business logic in it.
One usage can be the dictionary-based implementation of the PreferenceStorable
rather than the actual UserDefaults
. So UserDefaults
has two methods to set and retrieve the data. func set(_ value: Any?, forKey defaultName: String)
and func value(forKey key: String) -> Any?
. Let us invert the dependency. Rather than depending on UserDefaults let us make our system depend on abstraction, protocol
.
protocol UserDefaultsStorage {
func set(_ value: Any?, forKey defaultName: String)
func value(forKey key: String) -> Any?
}
The UserDefaultsStorage
encapsulate the methods that are required for setting and retrieving the value. So the UserDefaults
confirmation will be like the following.
extension UserDefaults: UserDefaultsStorage{}
And now, this should be enough to get going. But if we program against the abstraction, then we will use the UserDefaultsStorage
instead of UserDefaults
. That way, we can use another test double which can store the value in a dictionary rather than directly saving it to UserDefaults
.
class FakeStorage: UserDefaultsStorage{
var dictionary = [String: Any]()
func set(_ value: Any?, forKey defaultName: String) {
dictionary[defaultName] = value
}
func value(forKey key: String) -> Any? {
dictionary[key]
}
}
In the above code set and value methods use a dictionary to store and return the value. This is pure business logic to store the value in a non-persistence store rather than a persistence store, ie UserDefaults. So this is a Fake test double as it is using its business logic to store and deliver the stored result from within a Dictionary.
Mock
Mock always looks like Spy. But there is a distinct difference between Spy and Mock. On Spy, we just capture the values. The assertion happens in the corresponding test. On the other hand, in a mock, we do the verification in the mock itself.
In the mock, we expose verifications so that the test can assert the expected behavior. Following is an example of Mock.
class MockPUTAPI: PUTAPI{
private var isCalled = false
private var completion: ((Result)-> Void)?
func put(preference: Preference, completion: @escaping (Result)-> Void) {
isCalled = true
self.completion = completion
}
var verifyCall: Bool{
isCalled == true
}
var verifyCompletionSetup: Bool{
completion != nil
}
}
The verifyCall
and verifyCompletionSetup
does not explicitly expose the underlying value, but rather just confirms if they are set to an expected value. So now the corresponding tests will just assert these verifications.
func test_init_doesNotInitiateRemoteCall() {
let (_, mockAPI) = makeSUT()
XCTAssertFalse(mockAPI.verifyCall)
XCTAssertFalse(mockAPI.verifyCompletionSetup)
}
func test_put_initiateRemoteCall() {
let (sut, mockAPI) = makeSUT()
sut.executePUTRequest(for: URL(string: "any-url")!, with: .never){_ in}
XCTAssertTrue(mockAPI.verifyCall)
XCTAssertTrue(mockAPI.verifyCompletionSetup)
}
All the not-shown entities can be found on GitHub.
Conclusion
Swift Test Doubles such as Spy, Stub, Fake, and Mock gives us the ability to make our test deterministic. But knowing them is not just enough. We also need to know what difference they pose compared to one another. Also when to use which one? All these questions can not be answered in a single blog post. So we will be resuming our test doubles talks in the next talk. Till then take care.