Testing Mastery

Mock vs Stub vs Fake vs Spy

April 13, 2026
6 min read
Featured image for blog post: Mock vs Stub vs Fake vs Spy

You've heard these terms thrown around in testing discussions. Here's what they actually mean and when to use each.

Follow along with the code: iOS-Practice on GitHub

The Four Types

TypePurposeRemembers Calls?Has Logic?
StubReturns canned responsesNoNo
SpyRecords method callsYesNo
FakeWorking implementation with shortcutsMaybeYes
MockStub + Spy combinedYesNo

Stub: Returns Canned Data

A stub just returns what you tell it to. No tracking, no logic.

class StubPaymentProcessor: PaymentProcessor {
    var resultToReturn: PaymentResult = PaymentResult(
        transactionId: "txn_123",
        status: .success,
        processedAt: Date()
    )

    func processPayment(amount: Decimal, currency: String, cardToken: String) async throws -> PaymentResult {
        return resultToReturn
    }
}

// Usage
func test_placeOrder_whenPaymentSucceeds() async throws {
    let stub = StubPaymentProcessor()
    stub.resultToReturn = PaymentResult(transactionId: "txn_abc", status: .success, processedAt: Date())

    let sut = OrderService(paymentProcessor: stub, ...)
    let result = try await sut.placeOrder(...)

    XCTAssertNotNil(result)
}

Spy: Records Calls

A spy tracks what was called and with what arguments.

class SpyEmailService: EmailService {
    struct SendEmailCall: Equatable {
        let recipient: String
        let subject: String
        let body: String
    }

    private(set) var sendEmailCalls: [SendEmailCall] = []

    func sendEmail(to recipient: String, subject: String, body: String) async throws {
        sendEmailCalls.append(SendEmailCall(recipient: recipient, subject: subject, body: body))
    }

    var sendEmailCallCount: Int { sendEmailCalls.count }

    func verifySendEmailCalled(with recipient: String) -> Bool {
        sendEmailCalls.contains { $0.recipient == recipient }
    }
}

// Usage
func test_placeOrder_sendsConfirmationEmail() async throws {
    let spy = SpyEmailService()
    let sut = OrderService(emailService: spy, ...)

    _ = try await sut.placeOrder(customerEmail: "test@example.com", ...)

    XCTAssertEqual(spy.sendEmailCallCount, 1)
    XCTAssertTrue(spy.verifySendEmailCalled(with: "test@example.com"))
}

Fake: Working Implementation

A fake has real logic but takes shortcuts (like using memory instead of disk).

class FakeNotificationScheduler: NotificationScheduler {
    private var notifications: [String: ScheduledNotification] = [:]
    private var nextId = 1

    func scheduleNotification(title: String, body: String, at date: Date) throws -> String {
        let id = "notif_\(nextId)"
        nextId += 1
        notifications[id] = ScheduledNotification(id: id, title: title, body: body, scheduledDate: date)
        return id
    }

    func cancelNotification(id: String) throws {
        notifications.removeValue(forKey: id)
    }

    func getPendingNotifications() -> [ScheduledNotification] {
        Array(notifications.values)
    }

    // Test helper
    func hasNotification(withId id: String) -> Bool {
        notifications[id] != nil
    }
}

The InMemoryFileSystem and InMemoryKeyValueStore from previous posts are fakes.

Mock: Stub + Spy

Most test doubles in practice are mocks—they return canned data AND track calls.

class MockPaymentProcessor: PaymentProcessor {
    // Stub behavior
    var paymentResultToReturn: PaymentResult = PaymentResult(
        transactionId: "txn_123",
        status: .success,
        processedAt: Date()
    )
    var errorToThrow: Error?

    // Spy behavior
    struct ProcessPaymentCall {
        let amount: Decimal
        let currency: String
        let cardToken: String
    }
    private(set) var processPaymentCalls: [ProcessPaymentCall] = []

    func processPayment(amount: Decimal, currency: String, cardToken: String) async throws -> PaymentResult {
        processPaymentCalls.append(ProcessPaymentCall(amount: amount, currency: currency, cardToken: cardToken))
        if let error = errorToThrow { throw error }
        return paymentResultToReturn
    }

    // Helpers
    func setDeclinedResponse(reason: String) {
        paymentResultToReturn = PaymentResult(
            transactionId: "txn_declined",
            status: .declined(reason: reason),
            processedAt: Date()
        )
    }

    func reset() {
        paymentResultToReturn = PaymentResult(transactionId: "txn_123", status: .success, processedAt: Date())
        errorToThrow = nil
        processPaymentCalls = []
    }
}

When to Use Each

  • Stub: You only care about the return value
  • Spy: You need to verify something was called correctly
  • Fake: You need working behavior but want to avoid real I/O
  • Mock: You need both return values and call verification (most common)

In practice, most test doubles are mocks. The distinction matters more for understanding intent than strict categorization.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch