Mocks vs Stubs vs Spies — iOS Test doubles deep dive 🏊🏼

Chetan Aggarwal
8 min readMar 5, 2024

--

Photo by Yomex Owo on Unsplash

“When I first delved into the art of writing test cases, my initial companion was Mocks — a tool I deemed indispensable at the time. I suspect many of you can recall that initial reliance on Mocks as you ventured into the intricate world of software testing. However, as my journey in Test-Driven Development (TDD) unfolded, I stumbled upon two more invaluable techniques — Stubs and Spies. The revelation of their capabilities and the nuanced power they bring to testing scenarios was a game-changer for me.

In this blog post, we’ll explore the use cases of Mocks, Stubs, and Spies, unraveling the scenarios where each shines. Join me as we navigate the intricacies of these testing techniques, understanding when to employ each for effective and comprehensive test coverage. So, let’s dive into the fascinating world of Mocks, Stubs, and Spies.”

Mock 👨🏻‍🔧

A mock is an object that simulates the behavior of a real object and verifies interactions between the system under test and its dependencies. It allows you to set expectations on method calls and verify whether those expectations are met during the test.

Use Cases:

  • A mock ensures that specific methods are called with the correct parameters.
  • It is useful for examining how the system interacts with its dependencies.
  • Verifying the number of times a method is called.

Example:

// Protocol defining the Networking Service functionality
protocol NetworkServiceProtocol {
func fetchData(from url: URL, completion: @escaping (Data?, Error?) -> Void)
}

// Mock Network Service Class implementing the NetworkServiceProtocol
class MockNetworkService: NetworkServiceProtocol {
var fetchDataCalled = false
var expectedURL: URL?

func fetchData(from url: URL, completion: @escaping (Data?, Error?) -> Void) {
fetchDataCalled = true
expectedURL = url

// Simulate fetching data for testing purposes
let mockData = Data()
completion(mockData, nil)
}
}

// DataFetcher Class utilizing the NetworkService
class DataFetcher {
let networkService: NetworkServiceProtocol

init(networkService: NetworkServiceProtocol) {
self.networkService = networkService
}

func fetchData() {
let url = URL(string: "https://example.com/data")!
networkService.fetchData(from: url) { (data, error) in
// Process fetched data or handle errors
}
}
}

// Test Case for DataFetcher using MockNetworkService
func testFetchData() {
// Create a mock network service object
let mockNetworkService = MockNetworkService()

// Inject the mock network service into the DataFetcher
let dataFetcher = DataFetcher(networkService: mockNetworkService)

// Trigger the method that should call the network service
dataFetcher.fetchData()

// Verify that fetchData method was called on the mock with the expected URL
XCTAssertTrue(mockNetworkService.fetchDataCalled)
XCTAssertEqual(mockNetworkService.expectedURL, URL(string: "https://example.com/data"))
}

Stub 👨🏻‍🎨

A stub is a test double that provides predetermined responses to method calls. It helps control the indirect inputs of the system under test. For example, you can create a Stub to provide “canned” HTTP responses (e.g., predefined JSON data or errors).

Use Cases:

  • Stubs are used to simulate predetermined responses from dependencies without executing their actual logic.
  • They help in isolating the system under test from external dependencies, allowing focused testing.
  • Stubs can be employed to emulate various error conditions or exceptional cases during testing.

Example:

// Protocol defining the Data Service functionality
protocol DataServiceProtocol {
func fetchData(completion: @escaping ([String]?, Error?) -> Void)
}

// Stub Data Service Class implementing the DataServiceProtocol
class StubDataService: DataServiceProtocol {
func fetchData(completion: @escaping ([String]?, Error?) -> Void) {
// Simulate returning predefined data for testing
let data = ["Item 1", "Item 2"]
completion(data, nil)
}
}

// DataProcessor Class utilizing the DataService
class DataProcessor {
let dataService: DataServiceProtocol

init(dataService: DataServiceProtocol) {
self.dataService = dataService
}

func fetchAndProcessData() {
dataService.fetchData { (data, error) in
// Process fetched data or handle errors
}
}
}

// Test Case for DataProcessor using StubDataService
func testFetchData() {
// Create a stub data service object
let stubDataService = StubDataService()

// Inject the stub data service into the DataProcessor
let dataProcessor = DataProcessor(dataService: stubDataService)

// Trigger the method that should fetch and process data
dataProcessor.fetchAndProcessData()

// Assert the expected behavior based on the stubbed data service response
}

Spy 🕵🏻‍♂️

A spy is an object that records information about method calls, capturing details such as the number of invocations, arguments, etc. It enables developers to inspect and verify the interactions between the system under test and its dependencies.

Use Cases:

  • It allow to observe and verify the behavior of the system under test.
  • It ensure that specific methods are called with particular arguments.
  • While stubs provide predefined responses, spies are designed to focus on observing and capturing interactions.
  • It can capture and record the order in which methods are called.
  • Spying enables the capture of values during interactions, allowing to assert on them later.

Example:

// Protocol representing a database
protocol Database {
func saveUser(id: String, name: String)
func getUser(id: String) -> String?
}

// Class that utilizes the Database for user-related operations
class UserService {
let database: Database

init(database: Database) {
self.database = database
}

func registerUser(id: String, name: String) {
database.saveUser(id: id, name: name)
}

func getUserName(id: String) -> String? {
return database.getUser(id: id)
}
}

// Spy implementation of Database for testing purposes
class DatabaseSpy: Database {
var saveUserCalled = false
var getUserCalled = false

func saveUser(id: String, name: String) {
saveUserCalled = true
}

func getUser(id: String) -> String? {
getUserCalled = true
return "John Doe"
}
}

// Unit test using XCTest
class UserServiceTests: XCTestCase {
func testRegisterUser() {
let databaseSpy = DatabaseSpy()
let userService = UserService(database: databaseSpy)

// Act
userService.registerUser(id: "123", name: "Alice")

// Assert
XCTAssertTrue(databaseSpy.saveUserCalled)
}

func testGetUserName() {
// Arrange
let databaseSpy = DatabaseSpy()
let userService = UserService(database: databaseSpy)

// Act
_ = userService.getUserName(id: "456")

// Assert
XCTAssertTrue(databaseSpy.getUserCalled)
}
}

Spies with stubs 👫🏻

⚠️ Important Notes: ⚠️

A Spy is often also a Stub -> Spies can also complete the operation of completion with pre defined success or failure, which is similar to stubbing as well. This is the reason that spies are also often stubs.

A stub is not a Spy -> stubs are not spies since they don’t capture values.

Now that we’ve explored the concepts of both spies and stubs, let’s delve into a more advanced and detailed example that employs both techniques

Example:

// Protocol representing a network service
protocol NetworkService {
func fetchData(completion: @escaping (Result<String, Error>) -> Void)
}

// Class that utilizes the NetworkService for fetching data
class DataManager {
let networkService: NetworkService

init(networkService: NetworkService) {
self.networkService = networkService
}

func fetchDataAndUpdate(completion: @escaping (Bool) -> Void) {
networkService.fetchData { result in
switch result {
case .success(let data):
// Process the data (not shown in this example)
print("Data received: \(data)")
completion(true)
case .failure(let error):
print("Error fetching data: \(error.localizedDescription)")
completion(false)
}
}
}
}

// Spy implementation of NetworkService for testing purposes
class NetworkServiceSpy: NetworkService {
var fetchDataCompletions: [(Result<String, Error>) -> Void] = []

func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
// Capture the completion closure
fetchDataCompletions.append(completion)
}

// Method to simulate successful completion at a specific index
func completeWithSuccess(data: String, at index: Int) {
guard index < fetchDataCompletions.count else { return }
fetchDataCompletions[index](.success(data))
}

// Method to simulate failure completion at a specific index
func completeWithError(error: Error, at index: Int) {
guard index < fetchDataCompletions.count else { return }
fetchDataCompletions[index](.failure(error))
}
}

// Unit test using XCTest
class DataManagerTests: XCTestCase {
func testFetchDataAndUpdate_Success() {
// Arrange
let networkServiceSpy = NetworkServiceSpy()
let dataManager = DataManager(networkService: networkServiceSpy)
var completionResults: [Bool] = []

// Act
dataManager.fetchDataAndUpdate { result in
completionResults.append(result)
}

// Simulate network response by invoking the captured completion closure with success
networkServiceSpy.completeWithSuccess(data: "Simulated data", at: 0)

// Assert
XCTAssertEqual(completionResults, [true])
}

func testFetchDataAndUpdate_Failure() {
// Arrange
let networkServiceSpy = NetworkServiceSpy()
let dataManager = DataManager(networkService: networkServiceSpy)
var completionResults: [Bool] = []

// Act
dataManager.fetchDataAndUpdate { result in
completionResults.append(result)
}

// Simulate network response by invoking the captured completion closure with failure
let simulatedError = NSError(domain: "TestErrorDomain", code: 42, userInfo: nil)
networkServiceSpy.completeWithError(error: simulatedError, at: 0)

// Assert
XCTAssertEqual(completionResults, [false])
}
}

Bonus 🧧:

Dummy 🦸🏻‍♂️

Dummy is a test double act as a placeholder object that is passed around but not actually used. It is typically used when a parameter is required but its value is irrelevant to the test.

Use Cases:

  • Providing a placeholder object to fulfill method signatures.
  • Satisfying the parameter requirements of a method that is not directly tested.

Example:

// Protocol representing an authentication service
protocol AuthService {
func authenticate(username: String, password: String, completion: @escaping (Bool) -> Void)
}

// Class that utilizes the AuthService for networking operations
class NetworkClient {
let authService: AuthService

init(authService: AuthService) {
self.authService = authService
}

func fetchData(completion: @escaping (String) -> Void) {
// Simulated authentication before fetching data
authService.authenticate(username: "dummyUser", password: "dummyPassword") { success in
if success {
// Fetch data logic
completion("Mocked Data")
} else {
// Handle authentication failure
completion("Authentication Failed")
}
}
}
}

// Dummy implementation of AuthService for testing purposes
class DummyAuthService: AuthService {
func authenticate(username: String, password: String, completion: @escaping (Bool) -> Void) {
// Dummy implementation; no actual authentication logic
// Always assume authentication is successful for testing purposes
completion(true)
}
}

// Unit test using XCTest
class NetworkClientTests: XCTestCase {
func testFetchData() {
// Arrange
let dummyAuthService = DummyAuthService()
let networkClient = NetworkClient(authService: dummyAuthService)

// Act
networkClient.fetchData { result in
// Assert
XCTAssertEqual(result, "Mocked Data")
}
}
}

Fake 🧟

A fake is a simplified implementation of a dependency(or functional component) that mimics the real object’s behavior but in a more lightweight manner. Unlike a stub, a fake is not just a placeholder but provides a functional substitute for the real component. Fakes are useful for simulating external services or complex dependencies, enabling faster and more controlled testing. They are useful for speeding up tests by avoiding heavy real implementations.

Use Cases:

  • Fakes provide simplified versions of complex dependencies for testing purposes.
  • Simulating a database, network, or other external systems.

Example:

// Protocol defining the Cache functionality
protocol CacheProtocol {
func saveData(_ data: Any, forKey key: String)
func retrieveData(forKey key: String) -> Any?
}

// Fake Cache Class implementing the CacheProtocol
class FakeCache: CacheProtocol {
var cacheData: [String: Any] = [:]

func saveData(_ data: Any, forKey key: String) {
cacheData[key] = data
}

func retrieveData(forKey key: String) -> Any? {
return cacheData[key]
}
}

// DataManager Class utilizing the Cache
class DataManager {
let cache: CacheProtocol

init(cache: CacheProtocol) {
self.cache = cache
}

func saveDataToCache(_ data: Any, forKey key: String) {
cache.saveData(data, forKey: key)
}

func retrieveDataFromCache(forKey key: String) -> Any? {
return cache.retrieveData(forKey: key)
}
}

// Test Case for DataManager using FakeCache
func testCacheFunctionality() {
// Create a fake cache object
let fakeCache = FakeCache()

// Inject the fake cache into the DataManager
let dataManager = DataManager(cache: fakeCache)

// Save data to the fake cache
dataManager.saveDataToCache("Test Data", forKey: "key")

// Retrieve data from the fake cache
let retrievedData = dataManager.retrieveDataFromCache(forKey: "key")

// Assert that the retrieved data matches the saved data
XCTAssertEqual(retrievedData as? String, "Test Data")
}

Thank you for reading 🧑🏻‍💻

Be sure to clap 👏🏼 and follow 🚶🏻‍♂️

Questions❓Feedback 📫 — please drop you comments 💭

If you like this article, feel free to share it with your friends 📨

Follow me: Linkedin | X(Twitter) | Github 🤝🏼

--

--