Testing Mastery

Verifying Interactions with Spy Mocks

April 16, 2026
5 min read
Featured image for blog post: Verifying Interactions with Spy Mocks

Your test passes, but did the right methods get called with the right arguments? Spy mocks let you verify interactions, not just outcomes.

Follow along with the code: iOS-Practice on GitHub

The Spy Pattern

A spy records every call made to it, including arguments. This lets you assert on behavior, not just state.

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
        ))
    }

    // Verification helpers
    var sendEmailCallCount: Int { sendEmailCalls.count }

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

    func reset() {
        sendEmailCalls = []
    }
}

Using the Spy in Tests

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

    // Act
    _ = try await sut.placeOrder(
        amount: 50.00,
        currency: "USD",
        cardToken: "tok_123",
        customerEmail: "customer@test.com"
    )

    // Assert - Verify the email was sent
    XCTAssertEqual(spy.sendEmailCallCount, 1)
    XCTAssertTrue(spy.verifySendEmailCalled(with: "customer@test.com"))
    XCTAssertEqual(spy.sendEmailCalls[0].subject, "Order Confirmed")
}

Verifying No Interaction

Spies also let you verify that something didn't happen:

func test_placeOrder_whenPaymentDeclined_doesNotSendEmail() async {
    // Arrange
    let spy = SpyEmailService()
    mockPayment.setDeclinedResponse(reason: "Insufficient funds")

    // Act
    _ = try? await sut.placeOrder(...)

    // Assert - No email should be sent
    XCTAssertEqual(spy.sendEmailCallCount, 0)
}

Recording Complex Arguments

For methods with complex arguments, capture everything:

class SpyPaymentProcessor: PaymentProcessor {
    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
        ))
        return PaymentResult(transactionId: "txn_123", status: .success, processedAt: Date())
    }
}

// In test
func test_placeOrder_processesCorrectAmount() async throws {
    _ = try await sut.placeOrder(amount: 99.99, currency: "USD", ...)

    XCTAssertEqual(spy.processPaymentCalls[0].amount, 99.99)
    XCTAssertEqual(spy.processPaymentCalls[0].currency, "USD")
}

When to Use Spies

  • Verifying side effects (emails sent, analytics tracked)
  • Confirming correct arguments passed to dependencies
  • Ensuring methods called in the right order
  • Testing that errors prevent certain actions

Spies answer "what happened?" not just "did it work?"

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