The Swift testing ecosystem has long been dominated by traditional mocking frameworks that rely on runtime magic and type erasure. SwiftMocking takes a fundamentally different approach, leveraging Swift's most advanced language features to create something genuinely novel—a mocking library where type safety isn't an afterthought, but the foundation.
The Architecture Behind the Magic
What makes SwiftMocking unique isn't just its API, but its architectural foundation. Built on Swift 5.9's parameter packs, phantom types, and macros, it represents a complete rethinking of how test doubles should work in a type-safe language.
Parameter Packs: The Game Changer
At the heart of SwiftMocking lies an elegant use of parameter packs that enables something previously impossible—perfect type preservation across any function signature (aka arity):
class Spy<each Input, Effects: Effect, Output> {
// Can represent ANY function signature while maintaining complete type safety
}
// These spies capture the exact shape of their target functions:
let simpleSpy = Spy<String, None, Int>() // (String) -> Int
let asyncSpy = Spy<String, Async, Data>() // (String) async -> Data
let throwingSpy = Spy<String, Int, Throws, Bool>() // (String, Int) throws -> Bool
let asyncThrowsSpy = Spy<String, Int, Bool, AsyncThrows, [User]>() // (String, Int, Bool) async throws -> [User]
This approach eliminates the fundamental compromise that has plagued mocking frameworks: the choice between type safety and expressiveness. With SwiftMocking, you get both.
Effects as Phantom Types
Another characteristic of SwiftMocking's is how it models Swift's effects (async
, throws
). It does this by threading a phantom type through all the library types to ensure that only the correct tests can be written.
// The effect types thread through all library APIs
let spy = Spy<String, AsyncThrows, Data>()
when(spy(.any)).thenReturn { input in
// Compiler enforces that this closure must be async throws
await someAsyncOperation(input)
}
// This verification is also effect-aware
try await until(spy("test")) // Can only be called on async spies
The Self-Managing Mock Infrastructure
The Mock
base class represents another architectural innovation. Using @dynamicMemberLookup
, it automatically creates and manages spy instances on demand:
@dynamicMemberLookup
open class Mock: DefaultProvider {
subscript<each Input, Eff: Effect, Output>(
dynamicMember member: String
) -> Spy<repeat each Input, Eff, Output> {
// Lazily creates typed spies for protocol requirements
}
}
This design keeps generated code minimal while providing unlimited flexibility. Each mock is essentially a spy factory that creates exactly the right spy type for each method signature.
Macro-Free Testing
While the @Mockable
macro provides convenience, the core library is designed to work without it. This enables powerful closure-based testing patterns, for example:
// Test systems that use dependency injection with closures
struct APIClient {
let fetchUser: (String) async throws -> User
let updateUser: (User) async throws -> User
}
func testAPIFlow() async throws {
let fetchSpy = Spy<String, AsyncThrows, User>()
let updateSpy = Spy<User, AsyncThrows, User>()
when(fetchSpy(.any)).thenReturn(User(id: "123", name: "Test"))
when(updateSpy(.any)).thenReturn { user in
// Dynamic behavior with perfect type safety
User(id: user.id, name: user.name.uppercased())
}
let client = APIClient(
fetchUser: adapt(fetchSpy), // Convert spy to closure
updateUser: adapt(updateSpy)
)
let result = try await client.updateUser(User(id: "456", name: "test"))
verify(updateSpy(.any)).called()
}
Dynamic Stubbing with Type Safety
Traditional mocking frameworks force you to work with Any
parameters in dynamic stubs. SwiftMocking preserves exact types:
@Mockable
protocol Calculator {
func calculate(a: Int, b: Int) -> Int
}
when(calculator.compute(a: .even(), b: .even())).thenReturn { a, b in
a + b
}
when(calculator.compute(a: .odd(), b: .odd())).thenReturn { a, b in
a * b
}
Sophisticated Argument Matching
The argument matching system leverages Swift's literal protocols and range syntax for natural expressions:
// Swift ranges work naturally
verify(mock.setVolume(.in(0...100))).called()
verify(mock.processItems(.hasCount(in: 5...))).called()
// Literal values work without explicit matchers
verify(mock.authenticate("user@example.com")).called()
verify(mock.setFlag(true)).called(.greaterThan(2))
verify(mock.calculatePrice(199.99)).called()
Compact Code Generation
The macro generates remarkably clean code. For a simple protocol:
@Mockable
protocol PricingService {
func price(for item: String) throws -> Int
}
The entire generated mock is just:
#if DEBUG
class MockPricingService: Mock, PricingService {
func price(for item: ArgMatcher<String>) -> Interaction<String, Throws, Int> {
Interaction(item, spy: super.price)
}
func price(for item: String) throws -> Int {
return try adaptThrowing(super.price, item)
}
}
#endif
Two methods: one for the fluent testing API, one for protocol conformance. No bloat, no generated noise.
Practical Benefits
Test Isolation by Design
SwiftMocking leverages Swift's TaskLocal values to enable testing protocols with static requirements which would be impossible without this feature.
In a similar manner the library provides TestScoping
fallback values for unstubbed methods through suite and test traits, when using the Testing framework.
Framework Integration
The library works seamlessly with both XCTest and Swift Testing, all though it may be more convenient in when using the Testing
framework:
// Swift Testing with automatic test isolation
@Test(.mocking)
func testConcurrentExecution() {
// Each test gets isolated spy state
}
// XCTest with isolation through inheritance
class MyTests: MockingTestCase {
// Automatic spy isolation per test method
}
Fallback Value System
A sophisticated default value system prevents fatalError
for unstubbed methods:
// Built-in defaults for common types
let mock = MockUserService()
let name = mock.getUserName() // Returns ""
let age = mock.getUserAge() // Returns 0
// Custom types can participate
struct User: DefaultProvidable {
static var defaultValue: User { User(id: "", name: "") }
}
SwiftMocking is available on GitHub with comprehensive documentation, real-world examples, and integration guides for both XCTest and Swift Testing.
Top comments (0)