Testing delegates and protocols in XCTest
Table of Contents
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.
- Necessary preconditions and inputs.
- Action on the object or method under test.
- 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.
You can easily support sarunw.com by checking out this sponsor.
AI Grammar: Correct grammar, spell check, check punctuation, and parphrase.
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")
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.
You can easily support sarunw.com by checking out this sponsor.
AI Grammar: Correct grammar, spell check, check punctuation, and parphrase.
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.
Read more article about Swift, Testing, XCTest, or see all available topic
Enjoy the read?
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. No strings attached. Unsubscribe anytime.
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 Buy me a coffee Tweet ShareMake a placeholder view in SwiftUI with redacted()
SwiftUI provides an easy way to convert to render any view into a placeholder style by redacting its content.
3 lesser-known ways of using Swift enums
Three language features around Swift enumeration that you might not aware of.