Test Doubles part 1: Understanding Fakes

abdul ahad
5 min readJan 6, 2025

--

Photo by Ben Hershey on Unsplash

Understanding Test Doubles and their various types can be challenging for newcomers; this was certainly my experience. I aim to simplify this concept to make it more approachable for those just starting out.

What is Test Doubles?

Test Doubles are objects created for testing purposes — they replace "real" implementations with a fake one for testing.

For example, Spies, Stubs, Mocks, Fakes, etc. are all examples of Test Doubles.

Let’s look at briefly the types of Test Doubles.

Types of Test Doubles

  1. Dummy: Used to fill in parameters; never actually invoked.
  2. Stub: Returns predefined responses; used to control the SUT’s behavior.
  3. Mock: Verifies that certain interactions occurred.
  4. Spy: Captures information about interactions, like call counts and parameters.
  5. Fake: Implements real logic but in a simplified way (e.g., in-memory database).

Why do we need Test Doubles?

When building our apps, we often make use of dependencies, which can either be implicit or explicit (*I have a post lined up for explaining in detail both types of dependencies).

Explicit dependencies are dependencies that we inject into the system’s components rather than letting them create dependencies themselves through Dependency Injection. That makes it easier to replace real dependencies with test doubles in testing scenarios.

However, these dependencies introduce side effects, making tests slower, less reliable, and harder to maintain for the following reasons and more:

  1. Slow Execution: Real dependencies, such as databases or APIs, are slower than in-memory alternatives.
  2. Indeterministic Behavior: External systems can fail or behave unpredictably, leading to flaky tests.
  3. Complex Setup: Configuring real systems for tests can be time-consuming.
  4. Difficult to Test Edge Cases: Simulating specific conditions (e.g., network timeouts) is often complex with real dependencies.

To tackle these challenges, test doubles are used to isolate the system under test (SUT) from its dependencies. So in short,

In Production:

  • Real dependencies are injected to provide the required functionality.

In Testing:

  • Test doubles are injected to simulate dependency behavior.

Goal of Test Doubles

The primary goal of using test doubles is to isolate the SUT and focus on its behavior without worrying about external factors. Test doubles mimic the behavior of real dependencies, ensuring that tests remain:

  • Fast: No reliance on slow, external resources.
  • Deterministic: Always produce the same results.
  • Focused: Only test the SUT’s behavior, not its dependencies.

Understanding Fakes

What Is a Fake?

A Fake is a type of test double that provides a simplified implementation of a real component. It mimics the behavior of the real object but is lightweight and suitable for testing purposes. Unlike mocks and stubs, a fake often includes logic and state, enabling it to perform tasks similar to the real implementation but in a simpler way.

Using Fakes in iOS Testing

When developing robust and maintainable software, testing plays a critical role. In systems with dependencies, isolating the System Under Test (SUT) is essential to ensure tests are reliable, fast, and focused. One of the most powerful tools for achieving this is using fakes. In this article, we’ll explore the concept of fakes, how they differ from other test doubles, and implement an example in iOS Swift.

Why Use Fakes?

Dependencies like databases, APIs, or external services can introduce challenges in testing.

  1. Performance: Real dependencies are slower due to I/O operations.
  2. Unreliability: External systems can fail or behave unpredictably.
  3. Complexity: Setting up and managing real dependencies adds overhead to the testing process.

Fakes solve these issues by offering a fast, reliable, and isolated alternative for testing purposes.

Example Scenario: User Service with a Repository

Let’s explore an example where a UserService depends on a UserRepository for storing and retrieving user data. In production, the UserRepository interacts with in the filesystem for persistence. However, in tests, we’ll replace it with a fake implementation.

Production Code

protocol UserRepository {
func retrieveUser(by id: Int) -> User?
func saveUser(_ user: User)
}

class FileStorageUserRepository: UserRepository {
private let fileURL: URL
init(fileURL: URL) {
self.fileURL = fileURL
}
func retrieveUser(by id: Int) -> User? {
guard let data = try? Data(contentsOf: fileURL),
let users = try? JSONDecoder().decode([Int: User].self, from: data) else {
return nil
}
return users[id]
}
func saveUser(_ user: User) {
var users = (try? JSONDecoder().decode([Int: User].self, from: Data(contentsOf: fileURL))) ?? [:]
users[user.id] = user
if let data = try? JSONEncoder().encode(users) {
try? data.write(to: fileURL)
}
}
}

struct User {
let id: Int
let name: String
}

class UserService {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func getUser(by id: Int) -> User? {
return repository.retrieveUser(by: id)
}
func addUser(_ user: User) {
repository.saveUser(user)
}
}

In production, the DatabaseUserRepository interacts with a filesystem for persistent storage.

Fake Implementation

For testing purposes, we replace the DatabaseUserRepository with a fake implementation that uses in-memory storage.

class InMemoryUserRepositoryFake: UserRepository {
private var storage: [Int: User] = [:]

func retrieveUser(by id: Int) -> User? {
return storage[id]
}
func saveUser(_ user: User) {
storage[user.id] = user
}
}

This fake implementation mimics the behavior of a real repository but keeps all data in memory, making it lightweight and fast for testing.

Test Cases

With the fake in place, we can write focused and reliable tests for the UserService.

import XCTest
class UserServiceTests: XCTestCase {
func testAddAndRetrieveUser() {
// Arrange
let fakeRepository = InMemoryUserRepositoryFake()
let userService = UserService(repository: fakeRepository)
let testUser = User(id: 1, name: "John Doe")

// Act
userService.addUser(testUser)
let retrievedUser = userService.getUser(by: 1)
// Assert
XCTAssertNotNil(retrievedUser)
XCTAssertEqual(retrievedUser?.id, 1)
XCTAssertEqual(retrievedUser?.name, "John Doe")
}

func testRetrieveNonexistentUser() {
// Arrange
let fakeRepository = InMemoryUserRepositoryFake()
let userService = UserService(repository: fakeRepository)
// Act
let user = userService.getUser(by: 99)
// Assert
XCTAssertNil(user)
}
}

Advantages of Using Fakes

  1. Fast Execution: Fakes operate entirely in memory, avoiding the latency of database or network calls.
  2. Simplified Setup: Fakes are easy to configure and require no external dependencies.
  3. Reliable Tests: Tests using fakes are deterministic, producing consistent results.
  4. Focused Testing: Fakes help isolate the SUT, ensuring that tests validate its behavior without interference from external components.

Key Takeaways

  • Isolation: Fakes allow you to isolate the SUT and test its behavior without relying on real dependencies.
  • Lightweight: A fake implementation like InMemoryUserRepositoryFake provides real-world behavior in a simplified form, making it ideal for fast and reliable testing.
  • Maintainability: Using fakes keeps your tests focused and maintainable while avoiding the complexity of managing real dependencies.

By incorporating fakes into your testing strategy, you can ensure high-quality, efficient, and maintainable tests that focus on what matters most — the behavior of your system under test.

References:

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet