Kolos65 / Mockable

A Swift macro driven auto-mocking library.
MIT License
199 stars 14 forks source link

@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

Sponsorship

I'm working on Mockable as a fun side project. I believe in the power of open source, and I'm thrilled to share it with you. If Mockable saves you time and you’d like to show your appreciation, I would be incredibly grateful for your support.

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:

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:

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:

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:

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:

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:

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:

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

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

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

Limitations

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.