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?"