Skip to content
All essays
iOSMarch 27, 202514 min

Swift Testing Advanced: Mocking, Spies, and Fakes

Advanced Swift testing techniques. Mocking, spies, test doubles, and test architecture.

Ü
Ümit Uz
Mobile & Full Stack Developer

Master advanced testing techniques to write comprehensive, maintainable tests for Swift apps.

Test Doubles

Dummy Objects

swift
// Dummy - Used to satisfy parameter requirements
class DummyUserRepository: UserRepository {
  func fetchUser(id: String) throws -> User {
    fatalError("Not implemented")
  }

  func saveUser(_ user: User) throws {
    fatalError("Not implemented")
  }
}

// Usage
class UserServiceTests: XCTestCase {
  func testUserCreation() throws {
    let dummyRepository = DummyUserRepository()
    let service = UserService(repository: dummyRepository)
    // Test logic that doesn't require repository
  }
}

Test Stubs

swift
// Stub - Provides canned answers
class StubAPIService: APIService {
  var stubbedUser: User?

  override func fetchUser(id: String) async throws -> User {
    guard let user = stubbedUser else {
      throw APIError.notFound
    }
    return user
  }
}

// Usage
func testFetchUserSuccess() async throws {
  let stub = StubAPIService()
  stub.stubbedUser = User(id: "123", name: "John")

  let user = try await stub.fetchUser(id: "123")

  XCTAssertEqual(user.name, "John")
}

Mock Objects

swift
// Mock - Verifies interactions
class MockUserRepository: UserRepository {
  var saveCalled = false
  var savedUser: User?
  var fetchCalled = false
  var fetchId: String?

  func fetchUser(id: String) throws -> User {
    fetchCalled = true
    fetchId = id
    return User(id: id, name: "Test User")
  }

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

  func verifySaveCalled(expected: Bool, file: StaticString = #file, line: UInt = #line) {
    XCTAssertEqual(saveCalled, expected, "saveUser() call status", file: file, line: line)
  }

  func verifyFetchedUser(id: String, file: StaticString = #file, line: UInt = #line) {
    XCTAssertTrue(fetchCalled, "fetchUser() was not called", file: file, line: line)
    XCTAssertEqual(fetchId, id, "fetchUser() was called with wrong ID", file: file, line: line)
  }
}

// Usage
func testUserServiceSavesUser() throws {
  let mockRepository = MockUserRepository()
  let service = UserService(repository: mockRepository)

  try service.createUser(name: "John", email: "john@example.com")

  mockRepository.verifySaveCalled(expected: true)
  XCTAssertNotNil(mockRepository.savedUser)
  XCTAssertEqual(mockRepository.savedUser?.name, "John")
}

Spies

swift
// Spy - Captures information about calls
class SpyAPIService: APIService {
  struct Call {
    let method: String
    let parameters: [String: Any]
    let timestamp: Date
  }

  private(set) var calls: [Call] = []

  override func fetchUser(id: String) async throws -> User {
    calls.append(Call(
      method: "fetchUser",
      parameters: ["id": id],
      timestamp: Date()
    ))

    return User(id: id, name: "Spy User")
  }

  func verifyCallCount(method: String, expected: Int) {
    let actual = calls.filter { $0.method == method }.count
    XCTAssertEqual(actual, expected, "\(method) call count")
  }

  func verifyCallOrder(expectedMethods: [String]) {
    let actualMethods = calls.map { $0.method }
    XCTAssertEqual(actualMethods, expectedMethods, "Call order")
  }
}

// Usage
func testCallOrder() async throws {
  let spy = SpyAPIService()

  _ = try await spy.fetchUser(id: "1")
  _ = try await spy.fetchUser(id: "2")

  spy.verifyCallCount(method: "fetchUser", expected: 2)
}

Fake Objects

swift
// Fake - Working implementation but simplified
class InMemoryUserRepository: UserRepository {
  private var storage: [String: User] = [:]

  func fetchUser(id: String) throws -> User {
    guard let user = storage[id] else {
      throw RepositoryError.notFound
    }
    return user
  }

  func saveUser(_ user: User) throws {
    storage[user.id] = user
  }

  func clear() {
    storage.removeAll()
  }

  func count() -> Int {
    storage.count
  }
}

// Usage
func testUserCRUD() throws {
  let fakeRepository = InMemoryUserRepository()
  let user = User(id: "123", name: "John")

  try fakeRepository.saveUser(user)
  XCTAssertEqual(fakeRepository.count(), 1)

  let fetched = try fakeRepository.fetchUser(id: "123")
  XCTAssertEqual(fetched.name, "John")
}

Mocking Frameworks

Cuckoo

swift
import Cuckoo

// Generate mock with: cuckoo generate --testable MyApp

class MockUserService: UserService, Mock {
  var matcher: Matcher!
  var stubbing: Stubbing!
  var verification: Verification!

  init() {
    matcher = Matcher()
    stubbing = Stubbing()
    verification = Verification()
  }

  override func fetchUser(id: String) async throws -> User {
    let matcher: Matcher = self.matcher
    return try super.fetchUser(id: id)
  }
}

// Usage
func testWithCuckoo() async throws {
  let mock = MockUserService()

  stub(mock) { mock in
    when(mock.fetchUser(id: "123")).thenReturn(User(id: "123", name: "Test"))
  }

  let result = try await mock.fetchUser(id: "123")

  verify(mock).fetchUser(id: "123")
  XCTAssertEqual(result.name, "Test")
}

Dependency Injection for Testing

Protocol-Based Injection

swift
protocol NetworkService {
  func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T
}

class ProductionNetworkService: NetworkService {
  func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(T.self, from: data)
  }
}

class MockNetworkService: NetworkService {
  var result: Result<Any, Error> = .failure(NetworkError.unknown)

  func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
    switch result {
    case .success(let data):
      return data as! T
    case .failure(let error):
      throw error
    }
  }
}

// Usage in production
class ViewModel {
  private let network: NetworkService

  init(network: NetworkService = ProductionNetworkService()) {
    self.network = network
  }

  func loadUser() async throws -> User {
    try await network.fetch(User.self, from: userURL)
  }
}

// Usage in tests
func testLoadUser() async throws {
  let mock = MockNetworkService()
  mock.result = .success(User(id: "123", name: "Test"))

  let viewModel = ViewModel(network: mock)
  let user = try await viewModel.loadUser()

  XCTAssertEqual(user.name, "Test")
}

Testing Async Code

Async/Await Tests

swift
func testAsyncOperation() async throws {
  let result = try await slowOperation()

  XCTAssertEqual(result, "success")
}

func testAsyncError() async {
  do {
    _ = try await failingOperation()
    XCTFail("Should have thrown error")
  } catch {
    XCTAssertTrue(error is ExpectedError)
  }
}

func testMultipleAsyncCalls() async throws {
  async let first = fetchUser(id: "1")
  async let second = fetchUser(id: "2")

  let (user1, user2) = try await (first, second)

  XCTAssertEqual(user1.id, "1")
  XCTAssertEqual(user2.id, "2")
}

Time-Based Testing

swift
func testWithTimeout() async throws {
  try await withTimeout(seconds: 1.0) {
    _ = try await slowOperation()
  }
}

func withTimeout<T>(
  seconds: TimeInterval,
  operation: () async throws -> T
) async throws -> T {
  try await withThrowingTaskGroup(of: T.self) { group in
    group.addTask {
      try await operation()
    }

    group.addTask {
      try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
      throw TimeoutError()
    }

    guard let result = try await group.next() else {
      throw TimeoutError()
    }

    group.cancelAll()
    return result
  }
}

Testing UI

Snapshot Testing

swift
import SnapshotTesting

func testProfileViewSnapshot() {
  let view = ProfileView(user: User(id: "1", name: "John"))
  assertSnapshot(matching: view, as: .image(layout: .device(config: "iPhone 14 Pro")))
}

func testDetailViewDarkMode() {
  let view = DetailView(item: Item(id: "1", name: "Test"))
  assertSnapshot(matching: view, as: .image(precision: 0.9, layout: .device(config: "iPhone 14 Pro", appearance: .dark)))
}

View Modifiers Testing

swift
func testCustomModifier() {
  let view = Text("Hello")
    .modifier(CustomStyle())

  assertSnapshot(matching: view, as: .image)
}

Test Organization

Test Suites

swift
class UserTests: XCTestCase {
  // CRUD tests
}

class UserValidationTests: XCTestCase {
  // Validation tests
}

class UserAuthenticationTests: XCTestCase {
  // Authentication tests
}

class UserNetworkTests: XCTestCase {
  // Network integration tests
}

Shared Test Setup

swift
class TestCase: XCTestCase {
  var sut: SystemUnderTest!
  var mockDependencies: MockDependencies!

  override func setUp() {
    super.setUp()
    mockDependencies = MockDependencies()
    sut = SystemUnderTest(dependencies: mockDependencies)
  }

  override func tearDown() {
    sut = nil
    mockDependencies = nil
    super.tearDown()
  }
}

class UserTests: TestCase {
  func testUserCreation() {
    // Use sut and mockDependencies
  }
}

Best Practices

  1. 1Test Behavior: Test what, not how
  2. 2One Assertion: Prefer one assertion per test
  3. 3Descriptive Names: Use descriptive test names
  4. 4Independence: Keep tests independent
  5. 5Fast Tests: Keep tests fast
  6. 6Real Data: Use realistic test data
  7. 7Edge Cases: Test edge cases
  8. 8Maintainability: Maintain test quality

Advanced testing makes your code reliable!

Related essays

Next essay
Swift Macros: Code Generation and Metaprogramming