Test Doubles: Why Stub&Spies Are Preferred Over Mocks

rozeri dilar
iOS App Mastery
Published in
7 min readJun 17, 2023

When it comes to testing modules in integration, it’s beneficial to use end-to-end tests. However, it’s not practical or realistic to use end-to-end tests when testing individual modules. Let’s imagine a scenario where we have to handle multiple requests within milliseconds for each test call. Eventually, some of these requests will fail. This is where we need to consider whether we should mock the class/protocol or simply use stubs.

Using mocks creates a coupling between the implementation details, meaning we would spend our efforts tying the test to the implementation rather than testing the behavior. We shouldn’t have to worry about refactoring the test every time we refactor the production code. In most cases, this isn’t necessary. Instead, our tests should focus on the desired behavior. Instead of relying on mocks, we can use a clearer solution: stubbing.

When dealing with external dependencies, such as intercepting real network calls or faking camera authorization requests, test doubles will be quite useful.

Test doubles:

  • Dummy: A dummy is a placeholder or parameter used in testing to fulfill the requirements of a method or function. Dummies are used when an object or value is needed but its behavior or data is irrelevant to the specific test case. They are commonly used to satisfy the method’s signature without affecting the test outcome.
  • Fake: A fake is a substitute for a real component that provides an alternative implementation. It often simplifies or optimizes the behavior of the real component. Fakes are used when using the actual component in a test environment is impractical due to factors like slowness, resource consumption, or non-deterministic behavior. Fakes are useful for simulating external services or complex dependencies, enabling faster and more controlled testing.
  • Spies: Spies are mock objects that monitor and record interactions between the object under test and its dependencies. They allow you to observe and verify how the object interacts with its dependencies, including method invocations, arguments passed, and the order of calls. Spies are used to test collaborations between objects and ensure that the correct interactions occur.
  • Stubs: Stubs are mock objects that simulate the behavior of real objects and provide predefined responses to method calls. They are used to mimic the expected behavior of dependencies and control the outputs during testing. By setting up stubs with specific return values or behaviors, you can test the behavior of the object under test in different scenarios without relying on the actual implementation of the dependency. Stubs are helpful for isolating code paths, creating controlled test environments, and testing edge cases.
  • Mocks: Mocks are similar to stubs but with added assertions. They are pre-programmed stubs with expectations, forming a specification for the expected calls they should receive. Mocks can throw an exception if they receive an unexpected call and are verified during testing to ensure that all expected calls were made.

Why Stub&Spies Are Preferred Over Mocks:

While using mocks in testing is common, there are considerations and potential drawbacks to keep in mind:

Complexity: Mocks can make tests more complex as they require setting up expectations and assertions. Overusing mocks can result in tests that are difficult to maintain and can be as complex as the code being tested. Updating mocks after iterations in the production code can become burdensome.

Coupling: Mocks can create tight coupling between test code and the implementation details of the code being tested. If the implementation changes, the corresponding mocks may need updating even if the external behavior remains the same. This can lead to fragile tests that require frequent updates.

False Positives: Mocks can give a false sense of security. If the behavior of the code under test changes but the mocks are not updated accordingly, the tests may still pass even if the actual behavior is incorrect. This can result in tests that provide false positives and fail to catch real issues.

Focus on Implementation Details: Mocks often require setting expectations on specific method calls or parameter values, leading to tests that focus on implementation details rather than the desired behavior or outcomes. Such tests can become brittle and hinder refactoring or changes in the codebase.

Limited Coverage: Mocks may not fully cover the real-world interactions of the code being tested. While they provide controlled and isolated environments, they may not accurately simulate the complex behavior of external dependencies or real-world scenarios. Tests that pass with mocks may fail in actual usage.

It’s essential to use mocks judiciously, considering the trade-offs and ensuring that tests focus on desired behavior and outcomes rather than implementation details. Evaluating the benefits and drawbacks on a case-by-case basis and choosing appropriate testing techniques (such as spies and stubs) for each scenario is important.

In Swift development, “spy” and “stub” are commonly used terms to isolate and control dependencies during unit testing. Spies are effective for observing collaborations and verifying behavior, while stubs are valuable for creating controlled test environments and isolating code paths. Using spies and stubs appropriately can lead to more maintainable and focused tests that are less prone to false positives or brittleness compared to extensive use of mocks.

In More detail,

Spies:

Spies are useful in enhancing test assertions and observing collaborations between objects. They allow you to verify the number of method invocations, the arguments passed, and the order of calls. This helps ensure the correct behavior of the object under test and its interactions with dependencies. By using spies, you can focus on behavior verification and test the correctness of the system’s behavior.

Moreover, a spy records information about how it was used during the test. It allows you to observe and verify the interactions between the object under test and its dependencies. Spies keep track of method invocations, including the number of times a method was called, the arguments passed to it, and the order of method calls. By using spies, you can ensure that the expected collaborations between objects occur as intended.

For example, suppose you have a class called UserService that interacts with a Database object to perform user-related operations. During testing, you could create a spy for the Database object, which records method calls made to it by the UserService. You can then make assertions on the spy to verify that the UserService is correctly interacting with the Database.

Testing UserService using a Spy:

// Assume the UserService has a dependency on a Database protocol
protocol Database {
func saveUser(_ user: User)
}

// The implementation of UserService
class UserService {
let database: Database

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

func createUser(_ user: User) {
// Perform some user creation logic
// ...

// Save the user to the database
database.saveUser(user)
}
}

// Create a Spy for the Database protocol
class DatabaseSpy: Database {
var saveUserCalled = false
var savedUser: User?

func saveUser(_ user: User) {
saveUserCalled = true
savedUser = user
}
}

// Write a test case using the Spy for UserService
class UserServiceTests: XCTestCase {
func test_createUser_savesUserToDatabase() {
// Given
let databaseSpy = DatabaseSpy()
let userService = UserService(database: databaseSpy)
let user = User(name: "John Doe")

// When
userService.createUser(user)

// Then
XCTAssertTrue(databaseSpy.saveUserCalled, "saveUser should have been called")
XCTAssertEqual(databaseSpy.savedUser, user, "The saved user should be equal to the input user")
}
}

In this example, we create a DatabaseSpy that implements the Database protocol and keeps track of whether the saveUser method was called and the user that was passed to it. During the test, we instantiate the UserService with the spy as the database dependency. After invoking the createUser method, we can assert that the saveUser method was called on the spy and that the user passed to it matches the expected user.

Stubs:

Stubs, on the other hand, provide controlled responses to method calls on dependencies. They are used to simulate specific scenarios and create a controlled test environment. Stubs allow you to isolate code paths in the object being tested by providing predetermined responses. This enables you to test different scenarios and edge cases without relying on the actual implementation of the dependencies. Stubs can also improve test speed and determinism by replacing complex or slow dependencies with optimized alternatives.

Furthermore, a stub provides predefined responses or behavior for method calls on a dependency. Stubs are used to simulate the behavior of real objects and control their outputs during testing. By setting up stubs with specific return values or behaviors, you can test different scenarios without relying on the actual implementation of the dependency.

Stubs provide a controlled environment for testing, allowing you to focus on specific code paths and edge cases without worrying about the behavior of real dependencies.

For example, let’s consider a PaymentService class that depends on an external payment gateway. During testing, you could create a stub for the payment gateway, which overrides the actual network calls and returns predetermined responses. This allows you to simulate different payment scenarios, such as successful transactions, declined payments, or network errors, without relying on the actual payment gateway.

Testing PaymentService using a Stub:

// Assume the PaymentService depends on a PaymentGateway protocol
protocol PaymentGateway {
func processPayment(amount: Double) -> Bool
}

// The implementation of PaymentService
internal final class PaymentService {
private let paymentGateway: PaymentGateway

init(paymentGateway: PaymentGateway) {
self.paymentGateway = paymentGateway
}

func makePayment(amount: Double) -> Bool {
return paymentGateway.processPayment(amount: amount)
}
}

// Create a Stub for the PaymentGateway protocol
private final class PaymentGatewayStub: PaymentGateway {
private var shouldProcessPayment: Bool = true

func processPayment(amount: Double) -> Bool {
return shouldProcessPayment
}
}

// Write a test case using the Stub for PaymentService
class PaymentServiceTests: XCTestCase {
func test_makePayment_successesPayment() {
// Given
let paymentGatewayStub = PaymentGatewayStub()
let paymentService = PaymentService(paymentGateway: paymentGatewayStub)
let amount = 100.0

// When
let result = paymentService.makePayment(amount: amount)

// Then
XCTAssertTrue(result, "Payment should be successful")
}

func test_makePayment_failsPayment() {
// Given
let paymentGatewayStub = PaymentGatewayStub()
paymentGatewayStub.shouldProcessPayment = false
let paymentService = PaymentService(paymentGateway: paymentGatewayStub)
let amount = 100.0

// When
let result = paymentService.makePayment(amount: amount)

// Then
XCTAssertFalse(result, "Payment should fail")
}
}

In this example, we create a PaymentGatewayStub that conforms to the PaymentGateway protocol. The stub has a flag, shouldProcessPayment, which determines whether the processPayment method should return true or false. During the tests, we set up the stub with the desired behavior and instantiate the PaymentService with the stub as the payment gateway dependency. We then invoke the makePayment method and assert the expected results based on the stub's behavior.

By using spies and stubs appropriately in your tests, you can write more maintainable and focused tests. They provide a clearer focus on the behavior of the object under test and its interactions with dependencies. Compared to the extensive use of mocks, using spies and stubs can help reduce false positives and brittleness in your tests.

References

https://martinfowler.com/bliki/TestDouble.html

https://developer.apple.com/videos/play/wwdc2018/417

https://martinfowler.com/articles/mocksArentStubs.html

https://rozeridilar.com/2023/06/17/test-doubles-stubs-spies-over-mocking/

--

--