Mockable

repository·main·Indexed 19 days ago

https://github.com/kolos65/mockable

A Swift macro-driven testing framework that automatically generates mock implementations for protocols. It provides a declarative syntax using given, when, and verify to control behavior and assert interactions in unit tests. The framework supports associated types, async/throwing requirements, and custom parameter matching, while utilizing a MOCKING compile-time flag to ensure mocks are excluded from production builds.

Tokens
6.2K
Snippets
21
Records
28
Agent score
62%

What's inside Mockable

  1. Overview of Mockable

    main

    Mockable is a macro-driven testing framework for Swift that automatically generates mock implementations for your protocols. By leveraging the Swift macro system, it eliminates the need for external code-generation dependencies like Sourcery.

    Key features include:

    • Declarative API: Rapidly specify return values and verify method invocations in a readable format.
    • Advanced Swift Support: Supports associated types, generic functions, where clauses, and constrained generic arguments.
    • Build Safety: Generated mock implementations can be excluded from release builds using a built-in compile condition.
  2. Supported features in Mockable

    main

    Mockable supports a wide range of Swift protocol requirements for zero-boilerplate mock generation, including:

    • Protocols with associated types and constrained associated types
    • init requirements
    • Generic function parameters and return values (including those with where clauses)
    • Computed and mutable property requirements
    • @escaping closure parameters
    • Implicitly unwrapped optionals
    • throwing, rethrowing, and async requirements
    • Custom non-equatable types
  3. Mockable syntax and parameter matching

    main

    Mockable uses a declarative builder syntax: clause type(service).function builder.behavior builder.

    Parameter Matching

    When calling function builders, you must specify a condition for every parameter using the Parameter<Value> type:

    • .any: Matches any value.
    • .value(Value): Matches an identical value.
    • .matching((Value) -> Bool): Matches based on a custom closure.

    For mutable properties, use the (newValue:) parameter to constrain behavior on property assignment.

    // Match any ID
    given(productService).fetch(for: .any).willThrow(error)
    
    // Match specific property value
    when(productService).checkout(with: .matching { $0.name == "iPhone 15 Pro" }).perform { print("Ouch!") }
    
    // Match exact value
    verify(productService).fetch(for: .value(id)).called(.once)
  4. Exclude generated mocks from release builds using the MOCKING flag

    main

    The @Mockable macro wraps generated code in a compile-time check for the MOCKING flag. This ensures that mock implementations are only included in builds where the flag is defined (typically debug/test builds) and are excluded from release bundles.

    Because MOCKING is not defined by default, you must manually configure your build system to define this flag in your debug configurations to use the mocks.

    #if MOCKING
    public final class MockService: Service, Mockable {
        // generated code...
    }
    #endif
  5. Configure the MOCKING compile-time flag

    main

    Because @Mockable is a peer macro, generated code exists in the same scope as the protocol. To prevent mock implementations from being included in release builds, the macro expansion is wrapped in the MOCKING compile-time flag.

    You must define this flag in your project to use mocks.

  6. Specify parameter conditions with Parameter<Value>

    main

    When constructing given, when, or verify clauses, you must specify a condition for every parameter of a function using the Parameter<Value> type.

    Available options:

    • .any: Matches any value for that parameter.
    • .value(Value): Matches only calls where the parameter is identical to the provided value.
    • .matching((Value) -> Bool): Matches calls where the parameter satisfies the provided closure.

    Note on Properties:

    • Computed properties have no parameters.
    • Mutable properties receive a (newValue:) parameter in function builders, allowing you to constrain behavior based on the value being assigned.
    // Match any ID
    given(productService).fetch(for: .any).willThrow(error)
    
    // Match a specific ID
    given(productService).fetch(for: .value(id)).willThrow(error)
    
    // Match based on a property
    when(productService)
      .checkout(with: .matching { $0.name == "iPhone 15 Pro" })
      .perform { print("Ouch!") }
    
    // Match a specific assignment to a mutable property
    when(productService).url(newValue: .value(nil)).performOnSet { print("url set to nil") }
  7. How to use Mockable to write unit tests

    main

    Mockable uses the @Mockable macro to generate mock implementations for protocols. You can then use a declarative syntax to register return values (given), perform side effects (when), and assert behavior (verify).

    Workflow:

    1. Annotate a protocol with @Mockable.
    2. Instantiate the generated mock (e.g., MockProductService()).
    3. Use given(...) to define behavior.
    4. Execute the code under test.
    5. Use verify(...) to assert that the expected calls occurred.
    import Mockable
    
    @Mockable
    protocol ProductService {
        var url: URL? { get set }
        func fetch(for id: UUID) async throws -> Product
        func checkout(with product: Product) throws
    }
    
    // In your test:
    let productService = MockProductService()
    
    given(productService)
        .fetch(for: .any).willReturn(mockProduct)
    
    try await cartService.checkout(with: mockProduct, using: mockURL)
    
    verify(productService)
        .fetch(for: .any).called(.once)
  8. Install Mockable using a Package.swift manifest

    main

    If you are working with Swift Package Manager (SPM) modules or testing an SPM package, add Mockable as a dependency in your Package.swift file. You should add the Mockable product to both your main target and your test target definitions to ensure protocols can be mocked in both production and test environments.

    let package = Package(
        ...
        dependencies: [
            .package(url: "https://github.com/Kolos65/Mockable", from: "0.0.1"),
        ],
        targets: [
            .target(
                ...
                dependencies: [
                    .product(name: "Mockable", package: "Mockable")
                ]
            ),
            .testTarget(
                ...
                dependencies: [
                    .product(name: "Mockable", package: "Mockable")
                ]
            )
        ]
    )
  9. Install Mockable via Swift Package Manager

    main

    Add the Mockable target to all targets containing the protocols you wish to mock. Note that Mockable does not depend on XCTest, so it can be added to any target.

    When using the plugin for the first time, you must trust and enable it when prompted by Xcode.

    For unattended environments like CI, you can disable macro validation using one of these methods:

    • Use the xcodebuild flag: -skipMacroValidation
    • Set Xcode defaults: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES
    # Example xcodebuild flag for CI
    xcodebuild -skipMacroValidation
    
    # Example setting Xcode defaults
    defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES
  10. Configure the MOCKING flag in Xcode

    main

    If your project uses standard Xcode build settings, follow these steps to enable mocks for your targets:

    1. Open your Xcode project.
    2. Select your target and go to the Build Settings tab.
    3. Search for Swift Compiler - Custom Flags.
    4. Locate the Active Compilation Conditions section.
    5. Add the MOCKING flag under your debug build configuration(s).
    6. Repeat this for every target where you intend to use the @Mockable macro.