Hacking with Swift — Learning Project 39: Unit testing with XCTest
Introduction
This is the last project of the series and the next step will be alternating between learning new stuff, consolidating already known one and polishing existing projects to the point where I can really publish them!
I have some ideas but I will keep them for another day!
The only thing that scares me is that every app one publishes should have some “Legals” things attached, mostly for one’s own protection (the world is full of good and well-intentioned people, right?).
One does one go down that road? What’s the cost? What’s the risk? How does that work?! How does one learn that?!
If anyone reading this has any sound advice I would really be glad to hear your opinion!
Thank you!
Setting up
So, let’s see what this last project has in store for us!
Create a new Single View App including both Unit Test and UI Tests during creation. I called it “ShakespeareTesting”.
Add the “plays.txt” file to the project.
Change inheritance of the ViewController
class to be : UITableViewController
.
Open the storyboard, delete the view controller and drag out a new table view controller. Select it and change its class to be ViewController, then make it “Is Initial View Controller”, before embedding it into a navigation controller.
Select the table view’s cell, change its Style to Right Detail and its Identifier to “Cell”.
Creating our first unit test using XCTest
Create a new Swift File and, inside it, declare a new class PlayData
containing a single empty String array called allWords
.
Open the ShakespeareTestingTests.swift file. Delete the third and fourth method.
Create there a new method called testAllWordsLoaded()
:
- Create a new instance of the
PlayData()
class. - Call the
XCTAssertEqual()
method passingplayData.allWords.count
,0
and"allWords must be 0
as its parameters.
Now press the grey diamond next to this very method to run the test.
Loading our data and splitting up words: filter()
Change the arguments of the assertion to be 384001
and "allWords was not 384001"
.
Go back to PlayData.swift and create a new init()
for the class:
- If there can be found a path for the resource “plays” of type “txt” inside of the main app’s bundle, conditionally bind it to the
path
constant. - If this succeeds, optionally try to extract the content of the file
path
and cast it into aString
. If this succeeds conditionally bind it to theplays
constant. - Now, if we are still here, set the
allWords
property to be equal to the components of theplays
constant separated by the inverted version of the alphanumerics character set.
Open ViewController.swift and create a new playData = PlayData()
variable property.
Override the numberOfRowsInSection
table view method, making it return the count
property of the allWords
array of the playData
property.
Override the cellForRowAt
table view method, declaring and dequeuing a reusable cell with identifier “Cell” for the indexPath
parameter, a word
constant equal to the indexPath.row
-indexed element of the allWords
array of playData
, setting the cell’s text label’s text to be that word and then returning the cell.
Back in PlayData.swift, below the last written line, add a call that sets the allWords
array equal to itself with the filter
for non-empty strings run on it (in the form of .filter { $0 != "" }
.
Counting unique strings in an array
In PlayData.swift create a new property called wordCounts
of type [String:Int]
and initialise it.
Inside the initialiser, after the filter
, write a for-in
loop for every word in the allWords
array so that it accesses the wordCounts
dictionary’s entry for the word
constant and sets its value to what is found plus 1, while giving it a default value of 0 so that we have an initialised value.
After this, set the allWords
property to be an array of the keys of the wordCounts
dictionary.
Correct the testAllWordsLoaded()
so that the number is 18440.
In ViewController.swift set the text of the cell’s detail text label in the cellForRow
method to be a string interpolation of the value for the word
key of the wordCounts
dictionary of the playData
property.
measure()
: How to optimise our slow code and adjust the baseline
Inside ShakespeareTestingTests.swift create a new test called testWordsLoadQuickly()
passing it the measure
closure with, inside _ = PlayData()
.
Open by clicking the grey diamond in the gutter to the left of our code and click Set Baseline.
In PlayData.swift change the data type of the wordCounts
variable to NSCountedSet!
. Here is its description in the Documentation:
Summary
A mutable, unordered collection of distinct objects that may appear more than once in the collection.
Declaration
Discussion
Each distinct object inserted into an
NSCountedSet
object has a counter associated with it.NSCountedSet
keeps track of the number of times objects are inserted and requires that objects be removed the same number of times. Thus, there is only one instance of an object in anNSSet
object even if the object has been added to the set multiple times. The count method defined by the superclassNSSet
has special significance; it returns the number of distinct objects, not the total number of times objects are represented in the set. TheNSSet
andNSMutableSet
classes are provided for static and dynamic sets, respectively, whose elements are distinct.While
NSCountedSet
andCFBag
are not toll-free bridged, they provide similar functionality.
Erase everything after the filter
code and substitute it with the following:
- Set the
wordCounts
property to an instance ofNSCountedSet(array:)
passingallWords
as its only argument. - Set the
allWords
property to be equal towordCounts.allObjects
implicitly downcast as an array of Strings.
In ViewController.swift modify the detail label’s text string so that it reads "\(playData.wordCounts.count(for: word))"
.
Perform a similar change inside the testWordCountAreCorrect()
test.
Run the testWordsLoadQuickly()
test, then click the green diamond and then Edit > Accept > Save to update the average data to this new one.
Paul says this change in our code should have changed things by 2-3 times but my system noticed a 19% increase in performance only. Not bad, of course, but I think the Swift compiler got better since Paul first wrote this tutorial.
In PlayData.swift modify the code we just wrote so the we declare a new sorted
constant equal to the wordCounts
array’s allObjects
property, with the sorted
closure called on it. Inside the closure set the wordCounts.count(for:)
$0
greater than the one for $1
. Then set allWords
equal to sorted as! [String]
.
Now update the testWordsLoadQuickly()
average time because all this sorting will indeed slow the code (by indeed 32%!).
Enough for today, I’m going to continue tomorrow!
Filtering using functions as parameters
Add a new empty array of Strings called filteredWords
to PlayData.swift, the add an empty applyUserFilter
method which accepts a single input of type String at the end of the class, outside of the initialiser.
In ShakespeareTestingTests.swift add a testUserFilterWorks()
test method that creates a new PlayData()
instance and checks that, after applying the user filter on certain words, the count is what it should be.
Back in PlayData.swift start populating the applyUserFilter(_:)
method with a conditional binding of the integer version of the input
parameter to a constant and just writing a simple else
if the initialiser fails.
In the first case, set the filteredWords
array equal to the result of calling filter
on the allWords
array passing as condition that the count for the $0
parameter inside of the wordCounts
set is greater than or equal to the user number.
In the else
case, set the filtering condition to be that the range of the case insensitive input for the $0
parameter is not nil
. Translated for humans, that means, if the word contains that string in any part.
Now write a new applyFilter(filter: (String) -> Bool)
method that sets filteredWords
to be equal to the filtered array on allWords
when we pass filter
as an argument.
Now edit the applyUserFilter
method so that instead of the filteredWords = allWords.filter
it simply read applyFilter
. That’s it.
Modify now the definition of the filteredWords
array to be a private(set)
variable, so that everyone can read it but only the PlayData
class can write to it.
Updating the user interface with filtering
In ViewController.swift replace the two instances of allWords
with filteredWords
.
In PlayData.swift add, at the end of init()
, a call to applyUserFilter
passing in “swift” as a parameter to show the filter already. Instead, if you want as in my case to show all words at the beginning, write filteredWords = allWords
.
In ViewController.swift, create a new @objc
method called searchTapped()
. Inside:
- Create a new
UIAlertController
with a “Filter…” title, no message and an alert preferred style. - Add a text field to it.
- Add an alert action with a title of “Filter” and a default style.
- In its handler closure, capture
self
and the alert controller weakly, set_
as the action parameter and then store the text of the text field in a constant or set it to"0"
. Then apply the user filter with that input on theplayData
object and reload the table view data. - Add a cancel action to the alert controller
- Present it with an animation.
In viewDidLoad()
create a new right bar button item, with a system item of .search
, a target of self
and an action of #selector(searchTapped)
.
User interface testing with XCTest
Open the ShakespeareTestingUITests.swift file and delete the testExample()
method in there.
Write a new testInitialStateIsCorrect()
method there and, inside, declare a new table
constant equal to XCUIApplication().tables
. Then create an assertion to check that the count of the table’s cells is equal to 7 otherwise print the message “There should be 7 rows initially”.
Create now a new and empty testUserFilteringByString()
, put the cursor inside it and press the record button. Then, in the Simulator, tap the search button, write Filter then hit Search. Xcode will write some tests for us, which are much better in this version (10.3) than the one Paul wrote the tutorial in. Nevertheless, change the second line to be app.buttons["Search"].tap()
. Substitute the next with this:
let filterAlert = app.alerts
let textField = filterAlert.textFields.element
textField.typeText("test")
filterAlert.buttons["Filter"].tap()
XCTAssertEqual(app.tables.cells.count, 56, "There should be 56 words matching 'test'")
Wrap up!
This was the last project in the series and I am thrilled to move on!
There are a few challenges here, as usual:
Challenge 1: can you write a test that verifies there are 55 table rows when the user filters by words that appear 1000 or more times?
Done, here is the code:
func testRowsFor1000FrequentWords() {
let app = XCUIApplication()
app.buttons["Search"].tap()
let filterAlert = app.alerts
let textField = filterAlert.textFields.element
textField.typeText("1000")
filterAlert.buttons["Filter"].tap()
XCTAssertEqual(app.tables.cells.count, 55, "There should be 55 words that appear 1000 or more times.")
}
Challenge 2: can you write a test that ensures something sensible happens if the cancel button is pressed?
I’m not really sure something should happen here… why? I think I will fix the bug in Challenge 4 and then come back.
Challenge 3: can you write a performance test to ensure applyUserFilter()
doesn’t get any slower?
What I came about didn’t even start so… well… I tried to use a loop to pass in all the words but am not really sure how to do that because that didn’t work.
Challenge 4: fix the bug that gets triggered when the user press Filter after entering no string at all
Also this challenge is proving harder than I thought initially (that is, what I’m trying and what I knew should have worked didn’t work… why? I have no idea!).
Will I go to the browser and look for a solution? Nope! As I said I don’t like that, there must be another way!
One day later I managed to get to Xcode and solve challenge 2 and 4 together!
First challenge 4: inside PlayData.swift, modify the applyUserFilter(_:)
so that, in the else
branch, you check if the input is ""
(an empty string) and in that case you set filteredWords = allWords
! So, this fixes the bug.
Now, for challenge 2, you need to modify both the “Filter” action and the “Cancel” action. The filter action had the closure changed to this:
if let userInput = filterAC?.textFields?[0].text {
self?.playData.applyUserFilter(userInput)
} else {
self?.playData.applyUserFilter("")
}
self?.tableView.reloadData()
The cancel action, then, became this:
filterAC.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
self?.playData.applyUserFilter("")
self?.tableView.reloadData()
})
Now, the last thing is challenge 3, the test. I have looked online for this and found no real help. I asked some people who have been iOS developers for at least 3 years and they told me there is no way to do that …
I tried one thing because testing as I was doing it yesterday was not really optimised.
As we know that tests are run 10 times in a row I created a testFilterIsFast()
method then, inside:
- Called the
measure
closure - Created a
PlayData()
constant. - Created a
words
variable equal toplayData.allWords
. - Shuffled the array in place and printed the first word, to check it was working.
- Called
playData.applyUserFilter(words[0])
.
It actually worked and I got it to stay pretty consistent!
I do not know if this is correct but, hey, it works!
Bottom line
So it is over! I completed the Hacking with Swift book!
Sure, some old challenges remain unsolved but I will come back to them in the future!
For now my plan is as follows: I will alternate some new learning with building my own app, which I will keep you up to date in due time as soon as it gets usable!
Thank you so much to everyone who has followed me in this incredible journey, thanks to Paul Hudson for writing such an incredible book, thanks for all the support and love I have found in the community!
I know I say this every time but please don’t forget to drop a hello and a thank you to Paul for all his great work (you can find him on Twitter) and be sure to visit the 100 Days Of Swift initiative page. We have learnt and are learning so much thanks to him and he deserves to know of our gratitude.
He has written about 20 great books on Swift, all of which you can check about here (affiliate links so please use them if you want to support both his work and mine).
If you like what I’m doing here please consider liking this article and sharing it with some of your peers. If you are feeling like being really awesome, please consider making a small donation to support my studies and my writing (please appreciate that I am not using advertisement on my articles).
If you are interested in my music engraving and my publications don’t forget visit my Facebook page and the pages where I publish my scores (Gumroad, SheetMusicPlus, ScoreExchange and on Apple Books).
You can also support me by buying Paul Hudson’s books from this Affiliate Link.
Anyways, thank you so much for reading!
Till the next one!