Unit Testing on macOS: Part 2/2
In the second part of our Unit testing tutorial for macOS you’ll learn about UI tests and code coverage and you learn how to test asynchronous code. By Sarah Reichelt.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Unit Testing on macOS: Part 2/2
30 mins
Mocking
In the real code, URLSession
was used to start a URLSessionDataTask
which returned the response. Since you don’t want to access the internet, you can test that the URLRequest
is configured correctly, that the URLSessionDataTask
is created and that the URLSessionDataTask
is started.
You’re going to create mock versions of the classes involved: MockURLSession
and MockURLSessionDataTask
, which you can use instead of the real classes.
At the bottom of the WebSourcesTests.swift file, outside the WebSourceTests
class, add the following two new classes:
class MockURLSession: URLSession {
var url: URL?
var dataTask = MockURLSessionTask()
override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> MockURLSessionTask {
self.url = request.url
return dataTask
}
}
class MockURLSessionTask: URLSessionDataTask {
var resumeGotCalled = false
override func resume() {
resumeGotCalled = true
}
}
MockURLSession
sub-classes URLSession
, supplying an alternative version of dataTask(with:completionHandler:)
that stores the URL from the supplied URLRequest
and returns a MockURLSessionTask
instead of a URLSessionDataTask
.
MockURLSessionTask
sub-classes URLSessionDataTask
and when resume()
is called, does not go online but instead sets a flag to show that this has happened.
Add the following to the WebSourceTests
class and run the new test:
func testUsingMockURLSession() {
// 1
let address = "https://www.random.org/dice/?num=2"
guard let url = URL(string: address) else {
XCTFail()
return
}
let request = URLRequest(url: url)
// 2
let mockSession = MockURLSession()
XCTAssertFalse(mockSession.dataTask.resumeGotCalled)
XCTAssertNil(mockSession.url)
// 3
let task = mockSession.dataTask(with: request) { (data, response, error) in }
task.resume()
// 4
XCTAssertTrue(mockSession.dataTask.resumeGotCalled)
XCTAssertEqual(mockSession.url, url)
}
What’s going on in this test?
- Construct the
URLRequest
as before. - Create a
MockURLSession
and confirm the initial properties. - Create the
MockURLSessionTask
and callresume()
. - Test that the properties have changed as expected.
This test checks the first part of the process: the URLRequest
, the URLSession
and the URLSessionDataTask
, and it tests that the data task is started. What is missing is any test for parsing the returned data.
There are two test cases you need to cover here: if the data returns matches the expected format, and if it does not.
Add these two tests to WebSourcesTests.swift and run them:
func testParsingGoodData() {
let webSource = WebSource()
let goodDataString = "<p>You rolled 2 dice:</p>\n<p>\n<img src=\"dice6.png\" alt=\"6\" />\n<img src=\"dice1.png\" alt=\"1\" />\n</p>"
guard let goodData = goodDataString.data(using: .utf8) else {
XCTFail()
return
}
let diceArray = webSource.parseIncomingData(data: goodData)
XCTAssertEqual(diceArray, [6, 1])
}
func testParsingBadData() {
let webSource = WebSource()
let badDataString = "This string is not the expected result"
guard let badData = badDataString.data(using: .utf8) else {
XCTFail()
return
}
let diceArray = webSource.parseIncomingData(data: badData)
XCTAssertEqual(diceArray, [])
}
Here you have used expectations to test the network connection, mocking to simulate the networking to allow tests independent of the network and a third-party web site, and finally supplied data to test the data parsing, again independently.
Performance Testing
Xcode also offers performance testing to check how fast your code executes. In Roll.swift, totalForDice()
uses flatMap
and reduce
to calculate the total for the dice, allowing for the fact that value
is an optional. But is this the fastest approach?
To test performance, select the High RollerTests group in the File Navigator and use File\New\File… to create a new macOS\Unit Test Case Class named PerformanceTests
.
Delete the contents of the class and — you guessed it — add the following import as you’ve done before:
@testable import High_Roller
Insert this test function:
func testPerformanceTotalForDice_FlatMap_Reduce() {
// 1
var roll = Roll()
roll.changeNumberOfDice(newDiceCount: 20)
roll.rollAll()
// 2
self.measure {
// 3
_ = roll.totalForDice()
}
}
The sections of this function are as follows:
- Set up a
Roll
with 20Dice
. -
self.measure
defines the timing block. - This is the code being measured.
Run the test and you will see a result like this:
As well as getting the green checkmark symbol, you will see a speed indicator which in my test shows “Time: 0.000 sec (98% STDEV)”. The standard deviation (STDEV) will indicate if there are any significant changes from the previous results. In this case, there is only one result — zero — so STDEV is meaningless. Also meaningless is a result of 0.000 seconds, so the test needs to be longer. The easiest way to do this is to add a loop that repeats the measure block enough times to get an actual time.
Replace the test with the following:
func testPerformanceTotalForDice_FlatMap_Reduce() {
var roll = Roll()
roll.changeNumberOfDice(newDiceCount: 20)
roll.rollAll()
self.measure {
for _ in 0 ..< 10_000 {
_ = roll.totalForDice()
}
}
}
Run the test again; the result you get will depend on your processor, but I get about 0.2 seconds. Adjust the loop counter from 10_000
until you get around 0.2.
Here are three other possible ways of adding up the total of the dice. Open Roll.swift in the assistant editor and add them as follows:
func totalForDice2() -> Int {
let total = dice
.filter { $0.value != nil }
.reduce(0) { $0 + $1.value! }
return total
}
func totalForDice3() -> Int {
let total = dice
.reduce(0) { $0 + ($1.value ?? 0) }
return total
}
func totalForDice4() -> Int {
var total = 0
for d in dice {
if let dieValue = d.value {
total += dieValue
}
}
return total
}
And here are the matching performance tests which you should add to PerformanceTests.swift:
func testPerformanceTotalForDice2_Filter_Reduce() {
var roll = Roll()
roll.changeNumberOfDice(newDiceCount: 20)
roll.rollAll()
self.measure {
for _ in 0 ..< 10_000 {
_ = roll.totalForDice2()
}
}
}
func testPerformanceTotalForDice3_Reduce() {
var roll = Roll()
roll.changeNumberOfDice(newDiceCount: 20)
roll.rollAll()
self.measure {
for _ in 0 ..< 10_000 {
_ = roll.totalForDice3()
}
}
}
func testPerformanceTotalForDice4_Old_Style() {
var roll = Roll()
roll.changeNumberOfDice(newDiceCount: 20)
roll.rollAll()
self.measure {
for _ in 0 ..< 10_000 {
_ = roll.totalForDice4()
}
}
}
Run these tests and work out which option is the fastest. Did you guess which one would win? I didn't!
Code Coverage
The final Xcode test tool to discuss is code coverage, which is the measure of how much of your code is covered during a series of tests. It's turned off by default. To turn it on, select Edit Scheme... in the schemes popup at the top of the window. Select Test in the column on the left and then check Gather coverage data.
Close that window and press Command-U to re-run all the tests. Once the tests are complete, go to the Report Navigator and select the latest entry.
You'll see the test report showing a series of green checkmarks, plus some timings for the performance tests. If you don't see this, make sure both All toggles are selected at the top left.
Click on Coverage at the top of this display and mouse over the top of the blue bars to see that your tests cover nearly 80% of your code. Amazing work! :]
The two model objects (Dice
and Roll
) are very well covered. If you are only going to add some tests, the model is the best place to start.
There is another good, fast way to improve code coverage: delete code that isn't being used. Look at the coverage for AppDelegate.swift, it's at 50%.
Go to the AppDelegate.swift file. On the gutter on the right-hand side, mouse up and down and you’ll see it shows green for methods called during the tests, and red for methods that are not called.
In this case, applicationWillTerminate(_:)
is not used at all; it's dramatically decreasing the code coverage for this file. Since the app is not using this function, delete it. Now run all the tests again and AppDelegate.swift has jumped to 100% coverage.
This may seem to be cheating the system, but it is actually good practice to remove any dead code that is cluttering up your app. Xcode tries to be helpful when you make a new file and supplies lots of boilerplate code by default, but if you don't need any of this, delete it.
Now for the warning about code coverage: it is a tool, not a goal! Some developers and employers treat it as a goal and insist on a certain percentage. But it is possible to get a good percentage without testing meaningfully. Tests have to be well thought out and not just added for the sake of increasing your code coverage.
Tests may call numerous functions in your code without actually checking the result. While a high code coverage number is probably better than a low one, it doesn't say anything about the quality of the tests.