A Swift macro driven auto-mocking library.
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.
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.
Read Mockable’s documentation for detailed installation and configuration guides as well as usage examples.
The library can be installed using Swift Package Manager.
Add the Mockable target to all targets that contain protocols you want to mock. Mockable does not depend on the XCTest
framework so it can be added to any target.
Read the installation guide of the documentation for more details on how to integrate Mockable with your project.
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.
Define the flag in your target’s build settings for debug build configuration(s):
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))
]
)
Define the flag in your XcodeGen specification:
settings:
...
configs:
debug:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: MOCKING
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.
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 Mockable
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)
}
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 thrownFunction 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. ThesenewValue
conditions will only effect theperformOnGet
,performOnSet
,getCalled
andsetCalled
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)
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
}
}
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")
}
You can verify invocations of your mock service using the verify(_ service:)
clause.
Mockable supports both XCTest and Swift Testing by using Pointfree’s swift-issue-reporting to dynamically report test failures with the appropriate test framework.
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:
calledEventually(_:before:)
: Wait until timeout or invocation count satisfied.getCalledEventually(_:before:)
: Wait until timeout or property access count satisfied.setSalledEventually(_:before:)
: Wait until timeout or property assignment count satisfied.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)
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:
.relaxedMocked
[.relaxedVoid, .relaxedOptional]
.relaxed
.You have two options to override the default strict behavior of the library:
let relaxedMock = MockService(policy: [.relaxedOptional, .relaxedVoid])
MockerPolicy.default = .relaxedVoid
The .relaxedMocked
policy in combination with the Mocked
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: Mocked {
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: .relaxedMocked)
// 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.
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.
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.
Mockable is made available under the MIT License. Please see the LICENSE file for more details.