Easier, dynamic mocking for Swift.
Let's say we have a TestSubject
that depends on the Example
protocol.
protocol Example {
var foo: String { get }
func baz()
}
class TestSubject {
let example: Example
init(example: Example) {
self.example = example
}
func doAThing() {
if example.foo == "baz" {
example.baz()
}
}
}
If you've done unit testing in Swift, you're probably all too familiar with this dance:
class ManualMockExample: Example {
var foo: String
var bazWasCalled: Bool = false
init(foo: String) {
self.foo = foo
}
func baz() {
bazWasCalled = true
}
}
class ManualMockExampleTests : XCTestCase {
func testBoilerplateIsTedious() {
let manualMock = ManualMockExample(foo: "baz")
let testSubject = TestSubject(example: manualMock)
testSubject.doAThing()
XCTAssertTrue(manualMock.bazWasCalled)
}
}
These <func>WasCalled
flags and var
stubs are likely duplicated in every mock you write. If you need to throw an exception, invoke a completion block, or anything more complicated, your mocks get even more convoluted. Making matters worse, none of this "mock functionality" is easy to reuse across tests.
Scout aims to remove all of this boilerplate, and in doing so, make tests easier to both read and write. This is done using a declarative, functional, and dynamic API for creating and configuring mocks.
- Swift 5 or greater
Add this repo to your package's dependencies
, similar to this repo's Example project manifest.
There's a workaround for integrating Swift packages using Carthage. TL;DR;
- Add the project to your Cartfile
- Use Carthage to checkout the project
- Generate Scout's Xcode project:
cd Carthage/Checkouts/Scout && swift package generate-xcodeproj
- Follow Carthage's instructions to integrate Scout into your project
I recommend starting with Scout.playground
for a narrative, interactive guide. See Usage for code examples and API documentation.
Mock
is the entry point for all other APIs. It's meant to be embedded in a protocol-conformant mock class, like so:
protocol Example {
var foo: String { get }
func baz()
}
class MockExample : Example, Mockable {
let mock = Mock()
var foo: String {
get {
return mock.get.foo
}
}
func baz() {
try! mock.call.baz()
}
}
This example demonstrates the two APIs meant for use within a mock class:
Returns a @dynamicMemberLookup
proxy that retrieves the next expectation for the var
that's accessed. For example, to get the next expectation for the var foo
:
protocol GetExample {
var foo: String
}
class MockGetExample : GetExample, Mockable {
let mock = Mock()
var foo: String {
get {
return mock.get.foo
}
}
}
The dynamic member proxy is generic, meaning it uses type inference to determine that foo
should be a String
.
Returns a @dynamicCallable
proxy that will retrieve an expectation for the called function.
protocol CallExample {
func baz(buz: Int) -> Int
}
class MockCallExample : CallExample, Mockable {
let mock = Mock()
func baz(buz: Int) {
return try! mock.call.baz(buz: buz) as! Int
}
}
Make sure you pass all arguments from the wrapper class to the Mock
.
Since
call
is declared asthrows
(to support expecting an error), you'll need to addtry!
if it's being used in a function that isn't declaredthrows
.
As of Swift 5,
call
can't be made generic. Until Swift supports generic@dynamicCallable
types, you'll need to force-cast fromAny?
to the expected return type.
Returns a dynamic DSL object which configures the behavior of calls to mock.get.<var>
and mock.call.<func>
.
Simply access the desired var
, then call to
with the desired expectation:
mockGetExample.expect.foo.to(`return`("baz"))
mockGetExample.foo // returns "baz"
If there aren't any expectations when foo
is called, Mock
will fail the test. If there are still expectations left when verify()
is called, Mock
will fail the test.
Similar to var
expectations, call the desired function, followed by .to()
with the desired expectation as an argument:
mockCallExample.expect.baz(buz: equalTo(3)).to(`return`(4))
mockCallExample.baz(3) // returns 4
If baz
was called with something other than 3
, Mock
would have failed the test. Also, if baz
is called when no calls were expected, Mock
will fail the test. As shown above, you'll need to specify an ArgMatcher
when expecting a call to a function with arguments.
See ArgMatcher
for a list of available argument matchers. The two simplest are:
equalTo(value)
: checks that the argument is equal to the specified value.
any()
: accepts any argument.
If an argument fails to satisfy the specified matcher, Mock
will fail the test.
Once you've retrieved a var
or called a function on expect
, you need to set an expectation using the to()
method:
mockCallExample.expect.baz(buz: equalTo(3)).to(`return`(4))
In this case, the expectation is to "return 4." What you can expect varies based on whether you're expecting a var
or function call:
ExpectVarDSL
is used to expose the expectation DSL for var
access. The to
method only takes one form, and it accepts Expectation
instances returned by any of the factory functions:
return
: Return a single value one or more times (aliased asreturnValue
for the backtick averse).alwaysReturn
: Likereturn
, but always.get
: Return a value from a closure.
ExpectFuncDSL
provides the expectation-setting DSL for function calls. It has two different signatures:
The var
expectations DSL:
mockExample.to(`return`("foo"))
And another that accepts function-specific exepctations, which are just functions with the FuncExpectationBlock
signature. You can write your own:
func incrementBy(_ amount: Int) -> FuncExpectationBlock {
return { (args: KeyValuePairs<String, Any?>) in
return args.first as! Int + amount
}
}
mockExample.expect.foo.to(incrementBy(1))
This especially comes in handy when you have more advanced behaviors to expect, like calling a completion block.
There's also a throw
expectation for when you want your mock function to throw an error:
mockThrowExample.expect.someThrowingFunc.to(`throw`(SomeError())
Once you've set expectations on your mock, you'll need to verify that they're met.
At the bottom of a test method, you should call verify()
on your mock class if you want to assert that all of its expectations were met.
func testCallsBazIfFooIsBaz() {
mockExample.expect.foo.to(`return`("baz"))
mockExample.expect.baz().toBeCalled()
bazFunc(mockExample)
mockExample.verify()
}
In this example, the test will fail if bazFunc
doesn't call mockExample.baz()
.
Any expectations added using
toAlways
won't fail the test if they aren't called.
Once you've set up a Mockable
class, you can use some of Mock
's methods on it courtesy of the protocol extension which exposes some methods of Mock
on the class it's embedded in for convenience. This prevents you from having to type .mock.expect...
in all your tests.
Tests using mocks with Scout should set continueAfterFailure
to false
, otherwise the tests could crash due to unwrapping an unexpected nil
error.
There are a few things that @dynamicCallable
can't do:
- Generic return types. Workaround is to use
Any
and force-cast. inout
parameters. Workaround is to declare your mock class usinginout
and do a non-inout
call on the mock (see ExampleProject tests for an example).