Generating mock classes for unit testing in Swift

Using mocks for unit testing makes it easy to write test cases without configuring actual dependencies. However, it is hard to manually write and update all the mocking classes whenever there is a change. Here, Sourcery can help generate them automatically with a command.

Modern software is written in an object oriented paradigm where it consists of multiple objects dedicated to serve specific purposes. The foundation of testing those objects is unit testing. A unit test case is written by asserting desired side-effects after an operation is done to an object.

Let's think about a unit component for a counter app with an increment button. We need an object which manipulates and keeps track of counting. It is the core of the app encapsulating the main business logic.

protocol CounterDelegate: AnyObject {

    func counter(_ counter: Counter, didUpdateCount count: Int)
}

class Counter {

    weak var delegate: CounterDelegate?

    private(set) var count = 0 {
        didSet { delegate?.counter(self, didUpdateCount: count) }
    }

    func increment() {
        count += 1
    }
}

The Counter class has an integer property to store state and a function incrementing it. On calling increment function we expect it to update count property and notify the delegate of the change. Asserting count value is easy, as just performing an equality check is enough.

@testable import Tracker
import XCTest

final class CounterTests: XCTestCase {

    private var counter: Counter!

    override func setUp() {
        super.setUp()
        counter = Counter()
    }

    func testIncrement() {
        counter.increment()

        XCTAssertEqual(counter.count, 1)
    }
}

However, it is a bit trickier to test calling the delegate. To keep track of and assert function calls, we need to assign a mock object on the delegate. As Swift is a static-typed language, concrete implementation of the mock is required to compile. Manually writing all of those properties and functions is error-prone and time consuming, so we need software helping us on the task.

Introducing Sourcery


Sourcery generates boilerplate code for various purposes by parsing Swift files. One of the usage for it is to automatically create a mocking class. To produce CounterDelegateMock class for CounterDelegate:

  1. Install Sourcery with the instruction
  2. Download AutoMockable.stencil
  3. Mark protocol auto-mockable by adding a comment (We can also make it to confirm AutoMockable protocol)

// sourcery: AutoMockable
protocol CounterDelegate: AnyObject {

    func counter(_ counter: Counter, didUpdateCount count: Int)
}

Executing the following command outputs a file named AutoMockable.generated.swift containing CounterDelegateMock:

sourcery --sources <sources path> --templates <templates path> --output <output path>

class CounterDelegateMock: CounterDelegate {

    //MARK: - counter

    var counterDidUpdateCountCallsCount = 0
    var counterDidUpdateCountCalled: Bool {
        return counterDidUpdateCountCallsCount > 0
    }
    var counterDidUpdateCountReceivedArguments: (counter: Counter, count: Int)?
    var counterDidUpdateCountReceivedInvocations: [(counter: Counter, count: Int)] = []
    var counterDidUpdateCountClosure: ((Counter, Int) -> Void)?

    func counter(_ counter: Counter, didUpdateCount count: Int) {
        counterDidUpdateCountCallsCount += 1
        counterDidUpdateCountReceivedArguments = (counter: counter, count: count)
        counterDidUpdateCountReceivedInvocations.append((counter: counter, count: count))
        counterDidUpdateCountClosure?(counter, count)
    }

}

It implements detailed mocking functions automatically, make it possible to write sophisticated testing cases. Besides asserting if the function is called or not, checking the number of the function calls and arguments received can be useful for catching bugs or prevent regressions.

@testable import Tracker
import XCTest

final class CounterTests: XCTestCase {

    private var delegate: CounterDelegateMock!
    private var counter: Counter!

    override func setUp() {
        super.setUp()
        delegate = CounterDelegateMock()
        counter = Counter()
        counter.delegate = delegate
    }

    func testIncrement() {
        counter.increment()

        XCTAssertEqual(counter.count, 1)
        // Assert counter(_:, didUpdateCount:) is called
        XCTAssertTrue(delegate.counterDidUpdateCountCalled)
        // Assert received count parameter to be 1
        XCTAssertEqual(delegate.counterDidUpdateCountReceivedArguments?.count, 1)
    }
}

Now we have a complete set of class, delegate protocol, and mock delegate class consisting of a well-tested unit. During the process we only focused on actual business logic and use benefits from automatically generated code which is more than we wrote manually. This means increased productivity for better software.

Advantages of Using Sourcery for Mocking Class Generation

  1. Saving time by reducing repetitive typing: Mocking functions usually have a similar shape, call count, received arguments and return value. Therefore, it is better to automate such consistent and repetitive tasks.
  2. Easier maintenance: Mocks can be generated with a command very quickly. A command is enough after refactoring to keep the mocking class up-to-date.
  3. Better software architecture: Easier and more unit testing pushes us to write better software which is broken down into smaller and well-tested components.

Limitation of Sourcery for Mocking Class Generation

1. Does not support all kinds of functions: Functions having generic types are not compatible with Sourcery.
2. Cannot distinguish overloaded functions: As Sourcery strips out parameter names, overloaded functions cause duplicated symbol issue.
3. Manually importing modules: Currently modules imported should be done manually as Sourcery only parses, not analyze, the code.

Looking for a new challenge? Join Our Team

Like 5 likes
Jason Nam
Share:

Join the conversation

This will be shown public
All comments are moderated

Get our stories delivered

From us to your inbox weekly.