Mockable

0.0.7

A Swift macro driven auto-mocking library.
Kolos65/Mockable

What's New

Mockable 0.0.7

2024-04-07T19:27:41Z

What's Changed

  • Replace iOS 15 only Combine API with custom implementation #40 fixed in #42

Full Changelog: 0.0.6...0.0.7

@Mockable

Mockable is a Swift macro driven testing framework that provides automatic mock implementations for your protocols. It offers an intuitive declarative syntax that simplifies the process of mocking services in unit tests. The generated mock implementations can be excluded from release builds using compile conditions.

Table of Contents

Documentation

Read Mockable's documentation for detailed installation and configuration guides as well as usage examples.

Installation

The library can be installed using Swift Package Manager.

Mockable provides two library products:

  • Mockable: Core library containing the @Mockable macro.
  • MockableTest: Testing utilities that depend on the XCTest framework (will only link with test targets).

To use the library:

  1. Add Mockable to all of your targets that contain protocols you want to mock.
  2. Add MockableTest to your test targets.

Read the installation guide of the documentation for more details on how to integrate Mockable with your project.

Configuration

Since @Mockable is a peer macro, the generated code will always be at the same scope as the protocol it is attached to.

To solve this, the macro expansion is enclosed in a pre-defined compile-time flag called MOCKING that can be leveraged to exclude generated mock implementations from release builds.

⚠️ Since the MOCKING flag is not defined in your project by default, you won't be able to use mock implementations unless you configure it.

When using framework modules or a non-modular project:

Define the flag in your target's build settings for debug build configuration(s):

  1. Open your Xcode project.
  2. Go to your target's Build Settings.
  3. Find Swift Compiler - Custom Flags.
  4. Add the MOCKING flag under the debug configuration(s).

When using SPM modules or testing a package:

In your module's package manifest under the target definition, you can define the MOCKING compile-time condition if the build configuration is set to debug:

.target(
    ...
    swiftSettings: [
        .define("MOCKING", .when(configuration: .debug))
    ]
)

When using XcodeGen:

Define the flag in your XcodeGen specification:

settings:
  ...
  configs:
    debug:
      SWIFT_ACTIVE_COMPILATION_CONDITIONS: MOCKING

When using Tuist:

If you use Tuist, you can define the MOCKING flag in your target's settings under configurations.

.target(
    ...
    settings: .settings(
        configurations: [
            .debug(
                name: .debug, 
                settings: [
                    "SWIFT_ACTIVE_COMPILATION_CONDITIONS": "$(inherited) MOCKING"
                ]
            )
        ]
    )
)

Read the configuration guide of the documentation for more details on how to setup the MOCKING flag in your project.

Usage

Example

Given a protocol annotated with the @Mockable macro:

import Mockable

@Mockable
protocol ProductService {
    var url: URL? { get set }
    func fetch(for id: UUID) async throws -> Product
    func checkout(with product: Product) throws
}

A mock implementation named MockProductService will be generated, that can be used in unit tests like:

import MockableTest

lazy var productService = MockProductService()
lazy var cartService = CartServiceImpl(productService: productService)

func testCartService() async throws {
    let mockURL = URL(string: "apple.com")
    let mockError: ProductError = .notFound
    let mockProduct = Product(name: "iPhone 15 Pro")

    given(productService)
        .fetch(for: .any).willReturn(mockProduct)
        .checkout(with: .any).willThrow(mockError)

    try await cartService.checkout(with: mockProduct, using: mockURL)

    verify(productService)
        .fetch(for: .value(mockProduct.id)).called(.atLeastOnce)
        .checkout(with: .value(mockProduct)).called(.once)
        .url(newValue: .value(mockURL)).setCalled(.once)
}

Syntax

Mockable has a declarative syntax that utilizes builders to construct given, when, and verify clauses. When constructing any of these clauses, you always follow the same syntax:

clause type(service).function builder.behavior builder

In the following example where we use the previously introduced product service:

let id = UUID()
let error: ProductError = .notFound

given(productService).fetch(for: .value(id)).willThrow(error)

We specify the following:

  • given: we want to register return values
  • (productService): we specify what mockable service we want to register return values for
  • .fetch(for: .value(id)): we want to mock the fetch(for:) method and constrain our behavior on calls with matching id parameters
  • .willThrow(error): if fetch(for:) is called with the specified parameter value, we want an error to be thrown

Parameters

Function builders have all parameters from the original requirement but encapsulate them within the Parameter<Value> type. When constructing mockable clauses, you have to specify parameter conditions for every parameter of a function. There are three available options:

  • .any: Matches every call to the specified function, disregarding the actual parameter values.
  • .value(Value): Matches to calls with an identical value in the specified parameter.
  • .matching((Value) -> Bool): Uses the provided closure to filter functions calls.

Computed properties have no parameters, but mutable properties get a (newValue:) parameter in function builders that can be used to constraint functionality on property assignment with a match condition. These newValue conditions will only effect the performOnGet, performOnSet, getCalled and setCalled clauses but will have no effect on return clauses.

Here are examples of using different parameter conditions:

// throw an error when `fetch(for:)` is called with `id`
given(productService).fetch(for: .value(id)).willThrow(error)

// print "Ouch!" if product service is called with a product named "iPhone 15 Pro"
when(productService)
  .checkout(with: .matching { $0.name == "iPhone 15 Pro" })
  .perform { print("Ouch!") }

// assert if the fetch(for:) was called exactly once regardless of what id parameter it was called with
verify(productService).fetch(for: .any).called(.once)

Given

Return values can be specified using a given(_ service:) clause. There are three return builders available:

  • willReturn(_ value:): Will store the given return value and use it to mock subsequent calls.
  • willThrow(_ error:): Will store the given error and throw it in subsequent calls. Only available for throwing functions and properties.
  • willProduce(_ producer): Will use the provided closure for mocking. The closure has the same signature as the mocked function, so for example a function that takes an integer returns a string and can throw will accept a closure of type (Int) throws -> String.

The provided return values are used up in FIFO order and the last one is always kept for any further calls. Here are examples of using return clauses:

// Throw an error for the first call and then return 'product' for every other call
given(productService)
    .fetch(for: .any).willThrow(error)
    .fetch(for: .any).willReturn(product)

// Throw an error if the id parameter ends with a 0, return a product otherwise
given(productService)
    .fetch(for: .any).willProduce { id in
        if id.uuidString.last == "0" {
            throw error
        } else {
            return product
        }
    }

When

Side effects can be added using when(_ service:) clauses. There are three kind of side effects:

  • perform(_ action): Will register an operation to perform on invocations of the mocked function.
  • performOnGet(_ action:): Available for mutable properties only, will perform the provided operation on property access.
  • performOnSet(_ action:): Available for mutable properties only, will perform the provided operation on property assignment.

Some examples of using side effects are:

// log calls to fetch(for:)
when(productService).fetch(for: .any).perform {
    print("fetch(for:) was called")
}

// log when url is accessed
when(productService).url().performOnGet {
    print("url accessed")
}

// log when url is set to nil
when(productService).url(newValue: .value(nil)).performOnSet {
    print("url set to nil")
}

Verify

You can verify invocations of your mock service using the verify(_ service:) clause. There are three kind of verifications:

  • called(_:): Asserts invocation count based on the given value.
  • getCalled(_:): Available for mutable properties only, asserts property access count.
  • setCalled(_:): Available for mutable properties only, asserts property assignment count.

Here are some example assertions:

verify(productService)
    // assert fetch(for:) was called between 1 and 5 times
    .fetch(for: .any).called(.from(1, to: 5))
    // assert checkout(with:) was called between exactly 10 times
    .checkout(with: .any).called(10)
    // assert url property was accessed at least 2 times
    .url().getCalled(.moreOrEqual(to: 2))
    // assert url property was never set to nil
    .url(newValue: .value(nil)).setCalled(.never)

If you are testing asynchronous code and cannot write sync assertions you can use the async counterparts of the above verifications:

Here are some examples of async verifications:

await verify(productService)
    // assert fetch(for:) was called between 1 and 5 times before default timeout (1 second)
    .fetch(for: .any).calledEventually(.from(1, to: 5))
    // assert checkout(with:) was called between exactly 10 times before 3 seconds
    .checkout(with: .any).calledEventually(10, before: .seconds(3))
    // assert url property was accessed at least 2 times before default timeout (1 second)
    .url().getCalledEventually(.moreOrEqual(to: 2))
    // assert url property was set to nil once
    .url(newValue: .value(nil)).setCalledEventually(.once)

Relaxed Mode

By default, you must specify a return value for all requirements; otherwise, a fatal error will be thrown. The reason for this is to aid in the discovery (and thus the verification) of every called function when writing unit tests.

However, it is common to prefer avoiding this strict default behavior in favor of a more relaxed setting, where, for example, void or optional return values do not need explicit given registration.

Use MockerPolicy (which is an option set) to implicitly mock:

  • only one kind of return value: .relaxedMockable
  • construct a custom set of policies: [.relaxedVoid, .relaxedOptional]
  • or opt for a fully relaxed mode: .relaxed.

You have two options to override the default strict behavior of the library:

  • at mock implementation level you can override the mocker policy for each individual mock implementation in the initializer:
    let relaxedMock = MockService(policy: [.relaxedOptional, .relaxedVoid])
  • at project level you can set a custom default policy to use in every scenario by changing the default property of MockerPolicy:
    MockerPolicy.default = .relaxedVoid

The .relaxedMockable policy in combination with the Mockable protocol can be used to set an implicit return value for custom (or even built in) types:

struct Car {
    var name: String
    var seats: Int
}

extension Car: Mockable {
    static var mock: Car {
        Car(name: "Mock Car", seats: 4)
    }

    // Defaults to [mock] but we can 
    // provide a custom array of cars:
    static var mocks: [Car] {
        [
            Car(name: "Mock Car 1", seats: 4),
            Car(name: "Mock Car 2", seats: 4)
        ]
    }
}

@Mockable
protocol CarService {
    func getCar() -> Car
    func getCars() -> [Car]
}

func testCarService() {
    func test() {
        let mock = MockCarService(policy: .relaxedMockable)
        // Implictly mocked without a given registration:
        let car = mock.getCar()
        let cars = mock.getCars()
    }
}

⚠️ Relaxed mode will not work with generic return values as the type system is unable to locate the appropriate generic overload.

Working with non-equatable Types

Mockable uses a Matcher internally to compare parameters. By default the matcher is able to compare any custom type that conforms to Equatable (except when used in a generic function). In special cases, when you

  • have non-equatable parameter types
  • need testing specific equality logic for a custom type
  • have generic functions that are used with custom concrete types

you can register your custom types with the Matcher.register() functions. Here is how to do it:

// register an equatable type to the matcher because we use it in a generic function
Matcher.register(SomeEquatableType.self)

// register a non-equatable type to the matcher
Matcher.register(Product.self, match: { $0.name == $1.name })

// register a meta-type to the matcher
Matcher.register(HomeViewController.Type.self)

// remove all previously registered custom types
Matcher.reset()

If you see an error during tests like:

No comparator found for type "SomeType". All non-equatable types must be registered using Matcher.register(_).

Remember to add the noted type to your Matcher using the register() function.

Supported Features

  • Zero-boilerplate mock generation
  • Exclude mock implementations from production target
  • Protocols with associated types
  • Protocols with constrained associated types
  • Init requirements
  • Generic function parameters and return values
  • Generic functions with where clauses
  • Computed and mutable property requirements
  • @escaping closure parameters
  • Implicitly unwrapped optionals
  • throwing, rethrowing and async requirements
  • Custom non-equatable types

Limitations

  • Static Requirements: Static members cannot be used on protocols and are not supported.
  • Protocol Inheritance: Due to limitations of the macro system, inherited protocol requirements won't be implemented.
  • Rethrows functions: Rethrowing function requirements are always implemented with non-throwing functions.
  • Non-escaping function parameters: Non-escaping closure parameters cannot be stored and are not supported.
  • Subscripts are not supported (yet).
  • Operators are not supported (yet).

Contribution

If you encounter any issues with the project or have suggestions for improvement, please feel free to open an issue. I value your feedback and am committed to making this project as robust and user-friendly as possible.

The package manifest is set up to only contain test targets and test dependencies if an environment variable named MOCKABLE_DEV is set to true. This is done to prevent the overly zealous Swift Package Manager from downloading test dependencies and plugins, such as swift-macro-testing or SwiftLint, when someone uses Mockable as a package dependency.

To open the package with Xcode in "development mode", you need the MOCKABLE_DEV=true environment variable to be set. Use Scripts/open.sh to open the project (or copy its contents into your terminal) to be able to run tests and lint your code when contributing.

License

Mockable is made available under the MIT License. Please see the LICENSE file for more details.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

Last updated: Sat Apr 27 2024 11:27:13 GMT-0900 (Hawaii-Aleutian Daylight Time)