DEV Community

Daniel Cardona Rojas
Daniel Cardona Rojas

Posted on

SwiftMocking: Rethinking Test Doubles with Modern Swift

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] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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() } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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() 
Enter fullscreen mode Exit fullscreen mode

Compact Code Generation

The macro generates remarkably clean code. For a simple protocol:

@Mockable protocol PricingService { func price(for item: String) throws -> Int } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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: "") } } 
Enter fullscreen mode Exit fullscreen mode

SwiftMocking is available on GitHub with comprehensive documentation, real-world examples, and integration guides for both XCTest and Swift Testing.

Top comments (0)