Why does a hand-written spy fail to compile in Swift 6 when a protocol is Sendable, and how do you write doubles that are safe?
Predict first
What does the compiler say about a mutable class spy for a Sendable protocol?
// === Package.swift ===
// swift-tools-version:6.0
import PackageDescription
let package = Package(name: "Lib", targets: [.target(name: "Lib"), .testTarget(name: "LibTests", dependencies: ["Lib"])])
// === Sources/Lib/Lib.swift ===
public func square(_ x: Int) -> Int { x * x }
// === Tests/LibTests/T.swift ===
import Testing
@testable import Lib
protocol Mailer: Sendable { func send(_ to: String) async }
final class SpyMailer: Mailer {
var sent: [String] = []
func send(_ to: String) async { sent.append(to) }
}
@Test func spy() async { let m = SpyMailer(); await m.send("a"); #expect(m.sent == ["a"]) }
- It rejects the stored property as mutable in a Sendable class
- It compiles with a warning only
- It compiles and runs
Predict first
Do a locked class spy and an actor spy both record 200 concurrent calls?
// === Package.swift ===
// swift-tools-version:6.0
import PackageDescription
let package = Package(name: "Lib", targets: [.target(name: "Lib"), .testTarget(name: "LibTests", dependencies: ["Lib"])])
// === Sources/Lib/Lib.swift ===
public func square(_ x: Int) -> Int { x * x }
// === Tests/LibTests/T.swift ===
import Testing
import Foundation
@testable import Lib
protocol Mailer: Sendable { func send(_ to: String) async }
final class SpyMailer: Mailer, @unchecked Sendable {
private let lock = NSLock(); private var _sent: [String] = []
var sent: [String] { lock.withLock { _sent } }
func send(_ to: String) async { lock.withLock { _sent.append(to) } }
}
actor ActorSpy: Mailer {
private(set) var sent: [String] = []
func send(_ to: String) async { sent.append(to) }
}
func notifyAll(_ m: some Mailer, _ users: [String]) async {
await withTaskGroup(of: Void.self) { g in for u in users { g.addTask { await m.send(u) } } }
}
@Test func lockedSpy() async {
let m = SpyMailer(); await notifyAll(m, (0..<200).map(String.init))
#expect(m.sent.count == 200); print("LOCKED", m.sent.count)
}
@Test func actorSpy() async {
let m = ActorSpy(); await notifyAll(m, (0..<200).map(String.init))
let n = await m.sent.count; #expect(n == 200); print("ACTOR", n)
}
- Yes: both end with 200
- Only the actor does
- Both lose some calls
Mental model. A spy that several agents write to at once needs either a single clerk who takes one note at a time (actor) or a notebook with a lock (mutex). A loose notepad loses entries.
Test doubles are usually classes that mutate recorded calls, and they are passed into code that may call them from several tasks. In Swift 6 language mode a type conforming to Sendable must not have unprotected mutable state: the check shows the error “stored property 'sent' of 'Sendable'-conforming class 'SpyMailer' is mutable”. So a protocol declared Sendable (typical when the dependency is used from async code) forces the double to be thread-safe. The honest ways to do that are an actor (its methods are async to call and to read, so assertions become await spy.sent), a class guarding its state with a lock (NSLock, or Mutex from the Synchronization module on newer OS versions), marked @unchecked Sendable because you, not the compiler, vouch for the lock, or a struct with closures that capture a thread-safe box.
Both safe versions in the check receive 200 concurrent calls from a task group and end with exactly 200 recorded calls. The main reason to care is not only the compiler: a racy spy makes a test flaky, since two concurrent appends can lose an update. Choose the actor when the protocol methods are already async (reading the state costs an await), and the locked class when you need synchronous reads. Keep @unchecked Sendable confined to test code and to the one place the lock lives. If the code under test is main-actor-isolated, the double can simply be @MainActor as well, with no locking.
A Sendable class must protect or avoid mutable state.
Both synchronise their state, so no append is lost.
Common traps
- Silencing the error with
@unchecked Sendableand no lock. The compiler is satisfied and the race remains. - Making the spy an actor but reading it without
await. It does not compile outside the actor; read via an async accessor.
They might follow up with
- When is a plain class spy fine? When the dependency protocol is not
Sendableand everything runs on one actor. - Can I generate doubles with a macro? Yes; generated doubles face the same Sendable question.
Related questions
- 22.9.4 On which thread do tests run in Swift Testing, and how do you test code isolated to the main actor?
- 22.8.4 How does confirmation count events in Swift Testing, and what can it not do?
- 22.4.1 What is the difference between dummies, stubs, fakes, spies and mocks, and how do you decide which to use?
- 22.3.1 How do you test async code: async test functions, expectations, confirmations, actors and the main actor?
← How do XCTestExpectation tests fail: timeouts, inverted expectations… On which thread do tests run in Swift Testing, and how do you test… →