Skip to content

DanielCardonaRojas/swift-mocking

Repository files navigation

SwiftMocking

swift-version platforms license CI Status

SwiftMocking is a modern, type-safe mocking library for Swift that uses macros to provide a clean, readable, and efficient mocking experience. It offers an elegant API that leverages the power of parameter packs and @dynamicMemberLookup.



✨ Features

Feature Description
Type-Safe Mocking Uses parameter packs to keep mocks synchronized with protocol definitions, preventing runtime errors.
Clean, Readable API Provides a Mockito-style API that makes tests expressive and easy to maintain.
Flexible Argument Matching Offers powerful argument matchers like .any and .equal, with ExpressibleBy...Literal conformance for cleaner syntax.
Cross-Mock Call Order Verification Verify that method calls occurred in a specific sequence, even across different mock objects with verifyInOrder().
Effect-Safe Spies Models effects like async and throws as phantom types, ensuring type safety when stubbing.
Compact Code Generation Keeps the generated code as small and compact as possible.
Descriptive Error Reporting Provides clear and informative error messages when assertions fail, making it easier to debug tests.
Options to configure the macro generated code Exposes the MockableOptions OptionSet that enables selecting what and how code gets generated.
XCTest and Testing support SwiftMocking uses swift-issue-reporting and exposes testing utilities to both XCTest and swift-testing frameworks.
Test Isolation for Concurrency Provides isolation for concurrent test execution through TaskLocal.

Protocol Feature Support

Feature Supported
Associated Types
Variables
Static Methods
Generics
Subscripts
async Methods
throws Methods
Variadic parameters
Closure parameters
Metatype parameters

📦 Installation

To add SwiftMocking to your Swift package, add it as a dependency in your Package.swift file:

.package(url: "https://github.com/DanielCardonaRojas/swift-mocking.git", from: "0.5.0"),

Then, add SwiftMocking to your target's dependencies:

.target(
    name: "MyTests",
    dependencies: [
        .product(name: "SwiftMocking", package: "swift-mocking"),
    ]
),

🚀 Example

For a comprehensive demonstration of SwiftMocking's capabilities, including various mocking scenarios and advanced features, please refer to the Examples project.

Here's an example of how to use Mockable to mock a PricingService protocol:

import SwiftMocking


@Mockable
protocol PricingService {
    func price(_ item: String) throws -> Int
}
Generated Code
class PricingServiceMock: Mock, PricingService {
    func price(_ item: ArgMatcher<String>) -> Interaction<String, Throws, Int> {
        Interaction(item, spy: super.price)
    }
    func price(_ item: String) throws -> Int {
        return try adaptThrowing(super.price, item)
    }
}

Here is an example of a Store class that uses the PricingService.

class Store {
    var items: [String] = []
    var prices: [String: Int] =  [:]
    let pricingService: any PricingService
    init<Service: PricingService>(pricingService: Service) {
        self.pricingService = pricingService
    }

    func register(_ item: String) {
        items.append(item)
        let price = pricingService.price(for: item)
        prices[item] = price
    }
}

In your tests, you can use the generated MockPricingService to create a mock object and stub its functions.

import SwiftMocking
import XCTest

final class StoreTests: XCTestCase {
    func testItemRegistration() {
        let mock = MockPricingService()
        let store = Store(pricingService: mock)

        // Stub specific calls
        when(mock.price(for: "apple")).thenReturn(13)
        when(mock.price(for: "banana")).thenReturn(17)

        store.register("apple")
        store.register("banana")

        // Verify that price was called twice with any string
        verify(mock.price(for: .any)).called(2) // .called(2) is equivalent to .called(.equal(2))

        XCTAssertEqual(store.prices["apple"], 13)
        XCTAssertEqual(store.prices["banana"], 17)
    }
}

📚 Documentation

For more detailed information, please refer to the official documentation.


For detailed examples of how @Mockable expands different protocol definitions into mock implementations, see Generated Code Examples.

⚡️ Usage

Argument Matching

Mockable provides a rich set of argument matchers to precisely control stubbing and verification.

Matching Any Argument

// Stub a method to return a value regardless of the input string
when(mock.someMethod(.any)).thenReturn(10)

// Verify a method was called with any integer argument
verify(mock.anotherMethod(.any)).called()

Matching Specific Values (using .equal or literals)

// Stub a method to return 10 only when called with "specific"
when(mock.someMethod(.equal("specific"))).thenReturn(10)

// Verify a method was called exactly with 42 (using literal conformance)
verify(mock.anotherMethod(42)).called()

Matching Comparable Values (.lessThan, .greaterThan)

// Stub a method to return a value if the integer argument is less than 10
when(mock.processValue(.lessThan(10))).thenReturn("small")

// Verify a method was called with an integer argument greater than 100
verify(mock.processValue(.greaterThan(100))).called()

Range-Based Matching

// Using Swift's range syntax for more idiomatic matching
verify(mock.setVolume(.in(0...100))).called()        // ClosedRange: 0 through 100
verify(mock.validateAge(.in(18...))).called()        // PartialRangeFrom: 18 and above
verify(mock.setSpeed(.in(...65))).called()           // PartialRangeThrough: up to 65

// Collection count matching with ranges
verify(mock.processBatch(.hasCount(in: 5...10))).called()    // 5-10 items
verify(mock.handleLarge(.hasCount(in: 100...))).called()     // 100+ items
verify(mock.processSmall(.hasCount(in: ...3))).called()      // up to 3 items

Never Called Verification

// Verify a specific method was never called
verifyNever(mock.sensitiveMethod(password: .any))

// Verify a mock object had no interactions at all
let unusedMock = MockPricingService()
verifyZeroInteractions(unusedMock)  // Ensures mock was completely unused

Captured Argument Inspection

After verifying that methods were called, you can inspect the actual arguments that were passed using the captured method:

verify(mock.calculate(a: .any, b: .any))
    .captured { a, b in
        print("Called calculate with: a=\(a), b=\(b)")
        XCTAssertTrue(a + b > 0)
    }

Matching Object Identity (.identical)

class MyObject {}
let obj = MyObject()

// Stub a method to return a value only when called with the exact instance 'obj'
when(mock.handleObject(.identical(obj))).thenReturn("same instance")

Matching Optional Values (.notNil, .nil)

// Verify a method was called with a non-nil optional string
verify(mock.handleOptional(.notNil())).called()

// Stub a method to return a default value when called with a nil optional integer
when(mock.handleOptional(.nil())).thenReturn(0)

Matching Errors (.anyError, .error)

enum MyError: Error { case invalid }

// Verify a method threw any error
verify(mock.performAction()).throws(.anyError())

// Verify a method threw an error of type MyError
verify(mock.processData()).throws(.error(MyError.self))

Verifying Call Order Across Mocks

Verify that method calls occurred in a specific order, even across different mock objects:

let pricingMock = MockPricingService()
let analyticsMock = MockAnalyticsService()

when(pricingMock.price("apple")).thenReturn(13)

_ = try pricingMock.price("apple")
analyticsMock.logEvent("purchase")
_ = try pricingMock.price("banana")

// Verify the sequence of calls across both mocks
verifyInOrder([
    pricingMock.price("apple"),
    analyticsMock.logEvent("purchase"),
    pricingMock.price("banana")
])

Dynamic Stubbing

A powerful feature of SwiftMocking is that you can define the return value of a stub dynamically based on the arguments passed to the mocked function. This is achieved by providing a closure to thenReturn.

It is common in other testing frameworks, that the parameters of this closure be of type Any. However, thanks to the use of parameter packs, the set of arguments here are concrete types, and are guaranteed to match the types of the function signature that is being stubbed. This essentially enables substituting the mocked function dynamically. For example:

@Mockable
protocol Calculator {
    func calculate(a: Int, b: Int) -> Int
}

// Calculate summing
when(mock.calculate(a: .any, b: .any)).thenReturn { a, b in
    // Note that no casting is required, a and b are of type Int.
    return a + b
}
XCTAssertEqual(mock.calculate(a: 5, b: 10), 15)

// Replace the calculation function
when(mock.calculate(a: .any, b: .any)).thenReturn(*)
XCTAssertEqual(mock.calculate(a: 5, b: 10), 50)

Arranging Side Effects with do

Every when(...) call returns an arrangement object that can both stub return values and register side effects. Use .do { … } when you need to observe or mutate state without altering the stubbed response:

var events: [String] = []

when(mock.refresh(id: .equal("primary"))).do { id in
    events.append("refresh called with \(id)")
}

when(mock.refresh(id: .equal("primary"))).then()

mock.refresh(id: "primary")
XCTAssertEqual(events, ["refresh called with primary"])

For Void-returning interactions you can use the convenience alias then { … } instead of calling thenReturn(()). This keeps call sites concise while still allowing the same effect-specific APIs (throwing, async, async-throwing) shown above.

Logging Invocations

SwiftMocking provides a simple way to log method invocations on your mock objects. This can be useful for debugging tests and understanding the flow of interactions. You can enable logging on a per-instance or per-type basis.

Enabling Logging for a Mock Instance

To enable logging for a specific mock instance, set the isLoggingEnabled property to true.

let mock = MockPricingService()
mock.isLoggingEnabled = true

// Any calls to mock.instance methods will now be logged to the console.
_ = mock.price(for: "apple")
// Output: PricingServiceMock.price("apple")

Testing Methods with Callbacks

SwiftMocking excels at testing methods that use completion handlers or callbacks. This is particularly useful for testing asynchronous operations like network requests, file I/O, or any method that takes a closure parameter.

When testing callbacks, use the .any matcher for the callback parameter and the .then closure to control how the callback is executed:

@Mockable
protocol NetworkService {
    func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void)
}

func testNetworkServiceCallback() async {
    let mock = MockNetworkService()
    let expectation = XCTestExpectation()

    // Use .any matcher for the callback parameter
when(mock.fetchUser(id: .equal("123"), completion: .any)).then { id, completion in
        // Control when and how the callback is executed
        completion(.success(User(id: id, name: "Test User")))
    }

    mock.fetchUser(id: "123") { result in
        switch result {
        case .success(let user):
            XCTAssertEqual(user.name, "Test User")
            expectation.fulfill()
        case .failure:
            XCTFail("Expected success")
        }
    }

    await fulfillment(of: [expectation], timeout: 1.0)
}

Important: When testing methods with callbacks, always use the .any matcher for callback parameters, as it's the only matcher that makes sense for closure types.

Waiting for Asynchronous Interactions

When a system under test triggers a dependency inside a detached task, you can wait for the interaction with the until helper.

struct Controller {
    let refresh: (String) async throws -> Void

    func start() {
        Task {
            try await Task.sleep(for: .milliseconds(25))
            try await refresh("primary")
        }
    }
}

func testControllerRefreshesInBackground() async throws {
    let spy = Spy<String, AsyncThrows, Void>()
    let sut = Controller(refresh: adapt(spy))
    sut.start()
    try await until(spy("primary"))
    verify(spy("primary")).called()
}

Testing Closure-Based Dependencies

SwiftMocking also supports testing systems that use closures as dependencies instead of protocols. This is particularly useful for projects using The Composable Architecture (TCA) from Point-Free or similar dependency injection approaches.

// Define a struct with closure-based dependencies
struct FetchClient {
    var loadNumber: () async throws -> [Int]
    var saveNumber: (Int) async throws -> Void
}

func testClosureBasedDependencies() async throws {
    // Create spies for each closure
    let loadNumberSpy = Spy<Void, AsyncThrows, [Int]>()
    let saveNumberSpy = Spy<Int, AsyncThrows, Void>()

    // Stub the behaviors
    when(loadNumberSpy(.any)).thenReturn([1, 2, 3])
    when(saveNumberSpy(.any)).then { number in
        print("Saving number: \(number)")
    }

    // Create the client with adapted spies
    let client = FetchClient(
        loadNumber: adapt(loadNumberSpy),
        saveNumber: adapt(saveNumberSpy)
    )

    // Use the client
    let numbers = try await client.loadNumber()
    try await client.saveNumber(42)

    // Verify interactions
    XCTAssertEqual(numbers, [1, 2, 3])
    verify(loadNumberSpy(.any)).called(1)
    verify(saveNumberSpy(42)).called(1)
}

This approach provides the same testing capabilities as protocol-based mocking but works with closure-based dependency injection patterns. The adapt() function converts a Spy into a closure that can be used directly as a dependency.

Test Isolation for Concurrent Testing

SwiftMocking provides test isolation to ensure concurrent tests don't interfere with each other when using static mocks. This is essential for Swift Testing which runs tests in parallel by default.

  • XCTest: Inherit from MockingTestCase instead of XCTestCase for automatic spy isolation
  • Swift Testing: Use the @Test(.mocking) trait to enable test scoping

Without proper isolation, concurrent tests can experience race conditions where static spies accumulate calls from multiple tests, making verification assertions unpredictable.

Test-Scoped Default Values

SwiftMocking provides a powerful trait-based system for injecting custom default values that are scoped to individual tests or test suites. This allows you to provide specific default return values for unstubbed mock methods within the scope of a test execution.

Using .withDefaults Trait

import Testing
import SwiftMocking

@Test(.withDefaults("Test User", 42, true))
func testWithCustomDefaults() {
    let mock = MockUserService()

    // Unstubbed methods return the custom defaults
    let name = mock.getUserName()     // Returns "Test User"
    let age = mock.getUserAge()       // Returns 42
    let isActive = mock.isUserActive() // Returns true

    #expect(name == "Test User")
    #expect(age == 42)
    #expect(isActive == true)
}

Suite-Level Default Values

Apply default values to an entire test suite:

@Suite(.withDefaults("Default User"))
struct UserServiceTests {
    @Test
    func testUserCreation() {
        let mock = MockUserService()
        let name = mock.getUserName() // Returns "Default User"
    }

    @Test(.withDefaults("Override User"))
    func testWithOverride() {
        let mock = MockUserService()
        let name = mock.getUserName() // Returns "Override User"
    }
}

Benefits

  • Test Isolation: Each test gets its own isolated default value scope
  • Concrete Values: Use actual instances instead of static default implementations
  • Type Safety: Compile-time validation ensures type correctness
  • Flexible: Different tests can have different defaults for the same types
  • Composable: Works seamlessly with other traits like .mocking

Default Values for Unstubbed Methods

SwiftMocking provides a mechanism to return default values for methods that have not been explicitly stubbed. This is achieved through the DefaultProvidable protocol and the DefaultProvidableRegistry.

  • DefaultProvidable Protocol: Types conforming to this protocol can provide a defaultValue.
  • DefaultProvidableRegistry: This registry manages and provides access to default values for registered DefaultProvidable types.

Without a mechanism to provide default/fallback values when a method is not stubbed, calling the mock would unavoidably result in a fatalError.

For this reason, and to providide a less rigid testing experience, generated mocks include a defaultProviderRegistry property. This provides the flexibility of not having to stub every combination of arguments of a function, for certain return types.

By default, common Swift types like String, Int, Double, Float, Bool, Optional, Array, Dictionary, and Set conform to DefaultProvidable and are automatically registered.

// Assuming MyServiceMock is generated by @Mockable macro
let mock = MyServiceMock()

// If 'fetchData' is not stubbed, and its return type (e.g., String) is DefaultProvidable,
// it will return the default value for String ("")
let data = mock.fetchData() // data will be ""

You can also register your custom types that conform to DefaultProvidable:

struct MyCustomType: DefaultProvidable {
    static var defaultValue: MyCustomType {
        return MyCustomType(name: "Default", value: 0)
    }
    let name: String
    let value: Int
}

// Register your custom type with the shared registry
DefaultProvidableRegistry.shared.register(MyCustomType.self)

// Now, if a method returns MyCustomType and is unstubbed, it will return MyCustomType.defaultValue
let customValue = mock.getCustomType() // customValue will be MyCustomType(name: "Default", value: 0)

Descriptive Error Reporting

Mockable provides detailed error messages when a test assertion fails. For example, if you expect a function to be called 4 times but it was only called twice, you'll get a clear message indicating the discrepancy.

// Example of a failing test
verify(mock.price(for: .any)).called(4)

This will produce the following error:

error: Unfulfilled call count. Actual: 2

🤖 AI Agent Guide

SwiftMocking includes a comprehensive guide for AI coding assistants to help generate high-quality unit tests automatically. This guide enables AI tools like Claude Code, GitHub Copilot, and ChatGPT to understand SwiftMocking patterns and create consistent, well-structured tests.

Using the Agent Guide

The AGENT_GUIDE.md contains everything AI tools need to know about SwiftMocking, including:

  • Framework fundamentals and setup patterns
  • Stubbing and verification strategies
  • Argument matching techniques
  • Common testing scenarios and best practices
  • Integration with Swift Testing and XCTest

Quick Start for AI Tools

Copy this URL to provide the complete guide to your preferred AI coding assistant:

https://raw.githubusercontent.com/DanielCardonaRojas/swift-mocking/main/AGENT_GUIDE.md

Example Prompt:

Please fetch and review this SwiftMocking guide: https://raw.githubusercontent.com/DanielCardonaRojas/swift-mocking/main/AGENT_GUIDE.md

Then help me write comprehensive unit tests for my [YourService] protocol following the patterns and best practices in the guide.

⚙️ How it Works

SwiftMocking leverages the power of Swift macros to generate mock implementations of your protocols. When you apply the @Mockable macro to a protocol, it generates a new class that inherits from a Mock base class. This generated mock class conforms to the original protocol.

The Mock base class uses @dynamicMemberLookup to create and manage spies to for every protocol requirement.

A Spy has this structure:

let spy = Spy<ParamType1, ParamType2, ParamTypeN, Effect, ReturnType>()

// So for example:

// Represents a function signature: (Bool, Int) async throws -> String?
let methodSpy = Spy<Bool, Int, AsyncThrows, Optional<String>>()

The use of parameter packs here allows creating any number of parmeter types ParamType1 ... ParamTypeN.

This approach eliminates the need for manual mock implementations and provides a clean, expressive, and type-safe API for your tests.

⚠️ Known Limitations

Xcode Autocomplete

Currently, Xcode's autocomplete feature may not work as expected when using the generated mock objects. This seems to be a known issue with Xcode. This limitation could be worked around by conforming to the mocked protocol within an extension. However due to limitations of Swift macros, generating this extension will result in an error.

For example, the ideal generated code would separate the protocol conformance into an extension, like this:

// Ideal generated code
public protocol PricingService {
    func price(_ item: String) throws -> Int
}

class PricingServiceMock: Mock {
    func price(_ item: ArgMatcher<String>) -> Interaction<String, Throws, Int> {
        Interaction(item, spy: super.price)
    }
}

extension PricingServiceMock: PricingService {
    func price(_ item: String) throws -> Int {
        return try adaptThrowing(super.price, item)
    }
}

Xcode's autocomplete will prioritize methods in the order they are declared. Since mocks are usualy not interacted with directly we opt for declaring the Interaction methods first.

📜 License

This project is licensed under the MIT License - see the LICENSE file for details.

About

A compact swift mocking library powered by macros and parameter packs.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published