Scout

Easier, dynamic mocking for Swift.

Build Status codecov

Why Scout?

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.

Requirements

  • Swift 5 or greater

Installing

Swift Package

Add this repo to your package's dependencies, similar to this repo's Example project manifest.

Carthage

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

Getting Started

I recommend starting with Scout.playground for a narrative, interactive guide. See Usage for code examples and API documentation.

Usage

Mock

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:

Mock#get

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.

Mock#call

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 as throws (to support expecting an error), you'll need to add try! if it's being used in a function that isn't declared throws.

As of Swift 5, call can't be made generic. Until Swift supports generic @dynamicCallable types, you'll need to force-cast from Any? to the expected return type.

Mock#expect

Returns a dynamic DSL object which configures the behavior of calls to mock.get.<var> and mock.call.<func>.

Expecting Var Gets

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.

Expecting Function Calls

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.

Expecting Function 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.

Expectations

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:

Var Expectations

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 as returnValue for the backtick averse).
  • alwaysReturn: Like return, but always.
  • get: Return a value from a closure.

Function Expectations

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.

Mock#verify

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.

Mockable

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.

Caveats

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 using inout and do a non-inout call on the mock (see ExampleProject tests for an example).

Description

  • Swift Tools 5.0.0
View More Packages from this Author

Dependencies

  • None
Last updated: Mon Oct 21 2024 00:33:27 GMT-0900 (Hawaii-Aleutian Daylight Time)