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)