Testing delegates and protocols in XCTest

Swift Testing XCTest

Delegate is a common design pattern in the Apple world. You can see it everywhere in their framework. In this article, I will teach you how to test delegates in XCTest.

What we will cover #

When writing a unit test, we usually separate our group of codes into three sections.

  1. Necessary preconditions and inputs.
  2. Action on the object or method under test.
  3. Verify that the expected results have occurred.

You might have heard or adopted Arrange-Act-Assert (AAA), Given-When-Then (GWT), or other test cases structural out there. The core principle of these patterns and structures are the same, and what we're focusing on in this article is the last part, assertion and verification of the results.

System under test #

In this case, a system under test is a class with a delegate. As an example, we will create a new class and use it throughout this article. Temperature manager, a class that will keep updating its delegate about temperature changes.

protocol TemperatureManagerDelegate: AnyObject {
func temperatureManager(_ temperatureManager: TemperatureManager, didUpdateTemperature temperature: Double)
}

class TemperatureManager {
weak var delegate: TemperatureManagerDelegate?

func startUpdating() {
// Simulate of continuous update of temperature change.
DispatchQueue.global().async {
usleep(100)
for i in 0..<5 {
let newTemperature: Double = Double(i * 7)
DispatchQueue.main.async {
self.delegate?.temperatureManager(self, didUpdateTemperature: newTemperature)
}
}
}
}
}

We write a hard code implementation of startUpdating() to give out five temperature updates with 100ms interval.

0.0
7.0
14.0
21.0
28.0

Arrange-Act #

Arrange and act are easy parts. We could set it up with something like this.

class TemperatureManagerTests: XCTestCase {

func testTemperature() {
// Arrange
let manager = TemperatureManager()
manager.delegate = self

// Act
manager.startUpdating()

// Assert

}
}

Basic assertion #

We will start with a very basic implementation for testing delegates.

class TemperatureManagerTests: XCTestCase, TemperatureManagerDelegate { // 6

var temperature: Double? // 1
var expectation: XCTestExpectation? // 2

func testTemperature() throws {
// Arrange
let manager = TemperatureManager()
manager.delegate = self

// Act
expectation = expectation(description: "Temperature updates") // 3
manager.startUpdating()

// Assert
waitForExpectations(timeout: 1) // 4

let result = try XCTUnwrap(temperature)
XCTAssertEqual(result, 0) // 5
}

func temperatureManager(_ temperatureManager: TemperatureManager, didUpdateTemperature temperature: Double) {

self.temperature = temperature // 7
expectation?.fulfill() // 8
expectation = nil // 9
}
}

<1> We declare temperature variable to store an updated result from the delegate.
<2> We also use an expectation with <4> waitForExpectations to synchronize asynchonous update from the delegate.
<3> Declare an expectation before any action that is expected to fulfill that expectation.
<5> Assert a temperature from the delegate with our expected value.
<6> We make our test case a delegate of TemperatureManager.
<7> In delegate callback, we assign the update to our temperature variable, <8> fulfill the expectation, and <9> set our expectation to nil.

Execute the test, and it will fail with the following result.

XCTAssertEqual failed: ("28.0") is not equal to ("0.0")
XCTAssertEqual failed: (
XCTAssertEqual failed: ("28.0") is not equal to ("0.0")

The reason for this validation failed is the same as our multiple fulfill violation. Our update is too fast and gets called multiple times before our assertion, XCTAssertEqual(result, 0), get called.

We can solve this the same way as we solve a multiple fulfill violation.

func temperatureManager(_ temperatureManager: TemperatureManager, didUpdateTemperature temperature: Double) {

if expectation != nil { // 1
self.temperature = temperature
}
expectation?.fulfill()
expectation = nil
}

<1> We check if there is an expectation before store the temperature result.

With this simple fix, our test now passes.

Here is the final result.

class TemperatureManagerTests: XCTestCase, TemperatureManagerDelegate {

var temperature: Double?
var expectation: XCTestExpectation?

func testTemperature() throws {
// Arrange
let manager = TemperatureManager()
manager.delegate = self

// Act
expectation = expectation(description: "Temperature updates")
manager.startUpdating()

// Assert
waitForExpectations(timeout: 1)

let result = try XCTUnwrap(temperature)
XCTAssertEqual(result, 0)
}

func temperatureManager(
_ temperatureManager: TemperatureManager,
didUpdateTemperature temperature: Double) {

if expectation != nil {
self.temperature = temperature
}
expectation?.fulfill()
expectation = nil
}
}

Improvement #

Our basic implementation works well, but our test case is mixed up between the boilerplate code for test setup and what we really want to test. If we need to test this delegate elsewhere, we need to duplicate this boilerplate code everywhere. It would be better to capture this logic into a separate class.

We create a new MockTemperatureManagerDelegate which conform to our TemperatureManagerDelegate. This is quite a straightforward process. We move most of the code from TemperatureManagerTests to this newly created MockTemperatureManagerDelegate.

class MockTemperatureManagerDelegate: TemperatureManagerDelegate {
var temperature: Double? // 1
private var expectation: XCTestExpectation? // 2
private let testCase: XCTestCase // 3

init(testCase: XCTestCase) { // 4
self.testCase = testCase
}

func expectTemperature() { // 5
expectation = testCase.expectation(description: "Expect temperature")
}

// MARK: - TemperatureManagerDelegate
func temperatureManager(
_ temperatureManager: TemperatureManager,
didUpdateTemperature temperature: Double) { // 6

if expectation != nil {
self.temperature = temperature
}
expectation?.fulfill()
expectation = nil
}
}

<1> We move temperature and <2> expectation over.
<3> We keep a reference to XCTestCase by injecting it in the initializer <4>.
<5> A instance method to set an expectation of temperature. We use testCase that got injected to create an XCTestExpectation object.
<6> This is just a copy over as is.

And this is how we use it in our test case.

class TemperatureManagerTests: XCTestCase {
func testTemperature() throws {
// Arrange
let mockDelegate = MockTemperatureManagerDelegate(testCase: self) // 1
let manager = TemperatureManager()
manager.delegate = mockDelegate

// Act
mockDelegate.expectTemperature() // 2
manager.startUpdating()

// Assert
waitForExpectations(timeout: 1)

let result = try XCTUnwrap(mockDelegate.temperature) // 3
XCTAssertEqual(result, 0)
}
}

<1> Create a MockTemperatureManagerDelegate object.
<2> Set an expectation.
<3> Retrieve temperature result from MockTemperatureManagerDelegate.

That's it. We can remove the setup code out of the test case and make it more readable. We can reuse MockTemperatureManagerDelegate whenever we want to test the delegate.

Conclusion #

The delegation pattern might become less popular over time since a closure-based API and reactive programming gain popularity, but I think it still has its own place. So, you better know how to test it when the time comes. And I believe you can pick up some patterns in this article and apply it elsewhere.


Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Tweet Share

If you enjoy this article, you can subscribe to the weekly newsletter.

Every Friday, you’ll get a quick recap of all articles and tips posted on this site — entirely for free.

← Home