Spy
A spy is a test double that records how it was called so the test can inspect those calls afterwards. It is a stub with a memory: it can still return canned answers, but it also captures the arguments, order, and count of the calls it received, and leaves the judgment to the test.
How a spy works
A spy is injected in place of a real collaborator and quietly captures each call. After the action, the test reads what the spy recorded and asserts on it. This is interaction verification – the test checks the conversation between objects rather than a returned value – which makes a spy the natural tool when the behavior under test is an outgoing effect that leaves no state behind.
A spy and a mock both verify interactions; the difference is where the expectations live. A mock is told up front how it must be called and fails the test itself when reality disagrees. A spy stays passive – it records everything and judges nothing – and the test decides afterwards what to assert. That keeps the Arrange step free of expectations and puts the assertions where a reader expects to find them, at the cost of a little more test code.
The tradeoff
Reach for a spy when a unit’s job is to call a collaborator and there is no observable state to check instead – publishing an event, enqueuing a job, emitting a metric. Recording the calls and asserting after the fact keeps the test readable and the double simple.
The coupling that makes a spy useful also makes it a liability when overused. An assertion on exact arguments, call count, or order pins the unit’s current implementation in place, so a behavior-preserving refactor can still turn the test red. Spy only the calls that are genuinely part of the contract, and prefer asserting on observable state with a fake whenever the outcome is visible there instead.
Example
Closing an account must leave an audit trail, but that trail is written to a collaborator rather than returned. A spy captures the entries the service records so the test can assert exactly what was written.
// accounts.hpp -- the abstraction and the unit under test.
struct AuditEntry {
std::string action;
std::string subject;
};
class Auditor {
public:
virtual ~Auditor() = default;
virtual auto record(const AuditEntry& entry) -> void = 0;
};
class Accounts {
public:
explicit Accounts(Auditor& auditor) : m_auditor(&auditor) {}
auto close(const std::string& id) -> void;
private:
Auditor* m_auditor;
};
// A spy: records each entry so the test can inspect them afterwards.
class SpyAuditor final : public Auditor {
public:
auto record(const AuditEntry& entry) -> void override { m_entries.push_back(entry); }
auto entries() const -> const std::vector<AuditEntry>& { return m_entries; }
private:
std::vector<AuditEntry> m_entries;
};
TEST_CASE("close records an audit entry") {
// Arrange
SpyAuditor auditor;
Accounts accounts{auditor};
// Act
accounts.close("acc-1");
// Assert
REQUIRE(auditor.entries().size() == 1);
REQUIRE(auditor.entries().front().action == "account.closed");
REQUIRE(auditor.entries().front().subject == "acc-1");
}
package accounts
type AuditEntry struct {
Action string
Subject string
}
// Auditor records notable actions for later review.
type Auditor interface {
Record(entry AuditEntry)
}
// Accounts manages the lifecycle of customer accounts.
type Accounts struct {
auditor Auditor
}
func New(auditor Auditor) *Accounts {
return &Accounts{auditor: auditor}
}
// Close closes the account and records the action.
func (a *Accounts) Close(id string) {
// ... close the account ...
a.auditor.Record(AuditEntry{Action: "account.closed", Subject: id})
}
package accounts_test // external package: only the exported API is visible
// spyAuditor records the entries it receives for later inspection.
type spyAuditor struct {
entries []accounts.AuditEntry
}
func (s *spyAuditor) Record(entry accounts.AuditEntry) {
s.entries = append(s.entries, entry)
}
func TestCloseRecordsAuditEntry(t *testing.T) {
t.Parallel()
// Arrange
auditor := &spyAuditor{}
service := accounts.New(auditor)
// Act
service.Close("acc-1")
// Assert
want := []accounts.AuditEntry{{Action: "account.closed", Subject: "acc-1"}}
if got := auditor.entries; !cmp.Equal(got, want) {
t.Errorf("recorded %+v, want %+v", got, want)
}
}
# accounts.py -- the abstraction and the unit under test.
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class AuditEntry:
action: str
subject: str
class Auditor(Protocol):
def record(self, entry: AuditEntry) -> None: ...
class Accounts:
def __init__(self, auditor: Auditor) -> None:
self._auditor = auditor
def close(self, account_id: str) -> None:
# ... close the account ...
self._auditor.record(AuditEntry("account.closed", account_id))
from accounts import Accounts, AuditEntry
# A spy: records the entries it receives for later inspection.
class SpyAuditor:
def __init__(self) -> None:
self.entries: list[AuditEntry] = []
def record(self, entry: AuditEntry) -> None:
self.entries.append(entry)
def test_close_records_an_audit_entry():
# Arrange
auditor = SpyAuditor()
accounts = Accounts(auditor)
# Act
accounts.close("acc-1")
# Assert
assert auditor.entries == [AuditEntry("account.closed", "acc-1")]
// src/lib.rs -- the abstraction and the unit under test.
#[derive(Debug, PartialEq)]
pub struct AuditEntry {
pub action: String,
pub subject: String,
}
pub trait Auditor {
fn record(&self, entry: AuditEntry);
}
pub struct Accounts<'a> {
auditor: &'a dyn Auditor,
}
impl<'a> Accounts<'a> {
pub fn new(auditor: &'a dyn Auditor) -> Self {
Self { auditor }
}
pub fn close(&self, id: &str) {
// ... close the account ...
self.auditor.record(AuditEntry {
action: "account.closed".to_string(),
subject: id.to_string(),
});
}
}
// tests/accounts.rs -- a separate crate that sees only the public API.
use std::cell::RefCell;
use accounts::{Accounts, AuditEntry, Auditor};
// A spy: records the entries it receives for later inspection.
#[derive(Default)]
struct SpyAuditor {
entries: RefCell<Vec<AuditEntry>>,
}
impl Auditor for SpyAuditor {
fn record(&self, entry: AuditEntry) {
self.entries.borrow_mut().push(entry);
}
}
#[test]
fn close_records_an_audit_entry() {
// Arrange
let auditor = SpyAuditor::default();
let accounts = Accounts::new(&auditor);
// Act
accounts.close("acc-1");
// Assert
assert_eq!(
*auditor.entries.borrow(),
[AuditEntry { action: "account.closed".to_string(), subject: "acc-1".to_string() }],
);
}
Note
The spy asserts on what was recorded, not on how the service arrived there. Pin down the entry that is part of the audit contract and nothing more – an assertion on incidental extra calls would fail the next harmless refactor.References
- Mocks Aren't Stubs – distinguishes spies and mocks from stubs, and interaction verification from state verification.
- Test Spy – Gerard Meszaros’ catalog entry for the pattern.
- Test double – overview of the double family the spy belongs to.