Learning Swift — Days 161 to 163

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():

  1. Create a new instance of the PlayData() class.
  2. Call the XCTAssertEqual() method passing playData.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:

  1. 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.
  2. If this succeeds, optionally try to extract the content of the file path and cast it into a String. If this succeeds conditionally bind it to the plays constant.
  3. Now, if we are still here, set the allWords property to be equal to the components of the plays 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 an NSSet object even if the object has been added to the set multiple times. The count method defined by the superclass NSSet has special significance; it returns the number of distinct objects, not the total number of times objects are represented in the set. The NSSet and NSMutableSet classes are provided for static and dynamic sets, respectively, whose elements are distinct.

While NSCountedSet and CFBag are not toll-free bridged, they provide similar functionality.

Erase everything after the filter code and substitute it with the following:

  1. Set the wordCounts property to an instance of NSCountedSet(array:) passing allWords as its only argument.
  2. Set the allWords property to be equal to wordCounts.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:

  1. Create a new UIAlertController with a “Filter…” title, no message and an alert preferred style.
  2. Add a text field to it.
  3. Add an alert action with a title of “Filter” and a default style.
  4. 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 the playData object and reload the table view data.
  5. Add a cancel action to the alert controller
  6. 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:

  1. Called the measure closure
  2. Created a PlayData() constant.
  3. Created a words variable equal to playData.allWords.
  4. Shuffled the array in place and printed the first word, to check it was working.
  5. 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!

Published by Michele Galvagno

Professional Musical Scores Designer and Engraver Graduated Classical Musician (cello) and Teacher Tech Enthusiast and Apprentice iOS / macOS Developer Grafico di Partiture Musicali Professionista Musicista classico diplomato (violoncello) ed insegnante Appassionato di tecnologia ed apprendista Sviluppatore iOS / macOS

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: