Swift unit tests with 3rd party dependencies

Ignas Urbonas
Treatwell Engineering
4 min readMar 2, 2020

--

Anyone who has written tests have inevitably had to manage 3rd party dependencies. We don’t want to test those dependencies, instead we want to mock and inject them so that we can isolate our code leaving us to write tests that are both robust and deterministic.

Here at Treatwell we have found Swift protocols to be an elegant solution to the problem we’ve just described.

Life before Protocols

Before protocols, we’d often control a 3rd party dependency in a test by subclassing the original class and overriding the necessary methods, properties and initialisers. However, this came with some drawbacks:

  1. It limits what you can test — in Swift we are unable to override any method, property or initialiser with an access control lower than open. Nor is there any way to override static methods or static properties. You can’t subclass structs or enums either.
  2. Tests can become fragile — there is a risk that the class initialiser is overridden incorrectly which can cause unexpected behaviour, it’s also easy to cause unintended side effects by running real world code.
  3. It can become inefficient - instead of mocking what you need, you are forced to instantiate the whole class every time which starts to add up.

Protocols to the Rescue

Swift protocols should help to address some of the aforementioned issues by making tests easier to write, less fragile and more lightweight.

They allow us to create mocks or spies (when it’s just observing without imitation). By making 3rd party classes conform to protocols we can decouple them and run our tests in isolation.

To achieve this we need to:

  1. Create a protocol with blueprints which matches methods and properties of the original 3rd party class.
  2. Make sure that 3rd party class conforms to the protocol.
  3. Use created protocol as a dependency in your own class.
  4. Create a mock or a spy which conforms to the created protocol in tests target.
  5. Use it in tests 🎉.

Code Example

In this section we’ll use an example of what the aforementioned steps may look like in Swift.

In our example we have a 3rd party dependency called AnalyticsTracker:

open class AnalyticsTracker {
public init() {
// Initialise…
}
public func track(_ event: AnalyticsTrackerEvent) {
// Tracking action
}
}
public struct AnalyticsTrackerEvent {
public var category: String
public var label: String
}

Firstly, we create a protocol with a blueprint containing the track function which we want to mock:

protocol AnalyticsTrackerProtocol {
func track(_ event: AnalyticsTrackerEvent)
}

We then need to ensure that AnalyticsTracker conforms to the protocol we created in previous step:

extension AnalyticsTracker: AnalyticsTrackerProtocol {}

We are now able to define classes that reference the protocol, so as long as our 3rd party dependency conforms to it (as depicted above) we can pass it into any of our classes using a technique called Dependency Injection (DI):

final class MyClass {
private let tracker: AnalyticsTrackerProtocol

init(tracker: AnalyticsTrackerProtocol) {
self.tracker = tracker
}
func save() {
// Save …
let event = AnalyticsTrackerEvent(category: “Song”, label: “Thunderstruck”)
tracker.track(event)
}
}

Which means when we are testing, we can create a spy (or mock) that conforms to AnalyticsTrackerProtocol in test target, this could look like:

final class AnalyticsTrackerSpy: AnalyticsTrackerProtocol {
var trackedEvent: AnalyticsTrackerEvent?
func track(_ event: AnalyticsTrackerEvent) {
trackedEvent = event
}
}

Finally, we can substitute the real world 3rd party dependency with our spy in a test class:

class MyClassTests: XCTestCase {
func testSaveActionTracking() {
let tracker = AnalyticsTrackerSpy()
let sut = MyClass(tracker: tracker)
let expectedEvent = AnalyticsTrackerEvent(category: “Song”, label: “Thunderstruck”)
sut.save()
XCTAssertEqual(tracker.trackedEvent, expectedEvent)
}
}

That’s it. we now have an easy and lightweight technique which takes advantage of Swift protocols.

Default argument value

Some 3rd party classes have methods that have default argument values in their signatures. Default values are not supported in protocol methods blueprints. However, default values can be added to method signatures in protocol extensions.

In the example below, the track method with argument action with a default value:

open class AnalyticsTracker {
public func track(_ event: AnalyticsTrackerEvent, action: String? = nil) {
// Tracking action
}
}

In addition to defining our blueprint in AnalyticsTrackerProtocol we also need to add an extension.

protocol AnalyticsTrackerProtocol {
func track(_ event: AnalyticsTrackerEvent, action: String?)
}
extension AnalyticsTrackerProtocol {
func track(_ event: AnalyticsTrackerEvent) {
track(event, action: nil)
}
}

And now you can use it as if it’s the original AnalyticsTracker method:

let event = AnalyticsTrackerEvent(category: “Song”, label: “Thunderstruck”)
tracker.track(event)

Objective-c

In some cases additional work needs to be done to support bridging between objective-c (objc) and Swift code.

There are situations when the Swift protocol blueprint don’t match the objc class signature. An automatically generated Swift interface for objc class can have slightly different signatures depending on the methods implementation in .m and .h files. Common issues you may face when your created protocol and generated Swift interface for objc class differs:

  • default argument value exists in method signature as was shown in Default argument value example.
  • Argument label and parameter name are not exactly the same or label is missing.
  • Argument type can be defined as implicitly unwrapped if nullability annotation is missing in objc code.

Usually it is easy to create protocol blueprint by analyzing the generated Swift interface and looking at the actual objc method implementation.

Conclusion

This is a nice easy technique which adds a few benefits over subclassing, notably:

  1. It decouples 3rd party dependency in a protocol oriented manner.
  2. You have full control of 3rd party dependency in tests.
  3. It’s more lightweight.

Hope you will find this useful. I am always open for discussion.

--

--