20 Feb 2021
I’ve noticed that a fairly common thing to do in unit tests, especially when isolation isn’t perfect, is to update a value during a test and restore it to its original value after.
This is to avoid leaking state between tests.
Given some poorly isolated code a test case might look something like this:
final class AccountViewControllerTests: XCTestCase {
var initialUser: User?
override func setUp() {
super.setUp()
initialUser = Current.user // Capture the current state
Current.user = .authenticatedUser // Set new state for the test
}
override func tearDown() {
Current.user = initialUser // Restore the original state after the test
super.tearDown()
}
func testLogoutButtonIsVisible() {
let accountViewController = AccountViewController()
accountViewController.loadViewIfNeeded()
accountViewController.viewWillAppear(true)
XCTAssertFalse(accountViewController.logoutButton.isHidden)
}
// More tests
}
This is a lot of busy work and it’s easy to mess up.
Thankfully we can add a small extension to handle the caching dance for us:
extension XCTestCase {
func testCaseSet<T: AnyObject, U>(_ newValue: U, for keyPath: ReferenceWritableKeyPath<T, U>, on subject: T) {
let intitial = subject[keyPath: keyPath]
subject[keyPath: keyPath] = newValue
addTeardownBlock {
subject[keyPath: keyPath] = intitial
}
}
}
Using the above snippet we can more succinctly state that we want to upate a value for the duration of the test and don’t need to worry about how to actually do it.
final class AccountViewControllerWithExtensionTests: XCTestCase {
override func setUp() {
super.setUp()
testCaseSet(.authenticatedUser, for: \.user, on: Current)
}
func testLogoutButtonIsVisible() {
let accountViewController = AccountViewController()
accountViewController.loadViewIfNeeded()
accountViewController.viewWillAppear(true)
XCTAssertFalse(accountViewController.logoutButton.isHidden)
}
// More tests
}
Side note
The original example is long winded and could be made more concise by using addTeardownBlock
, which would look like this
final class AccountViewControllerTests: XCTestCase {
override func setUp() {
super.setUp()
let initialUser = Current.user // Capture the current state
Current.user = .authenticatedUser // Set new state for the test
addTeardownBlock {
Current.user = initialUser // Restore the original state after the test
}
}
func testLogoutButtonIsVisible() {
let accountViewController = AccountViewController()
accountViewController.loadViewIfNeeded()
accountViewController.viewWillAppear(true)
XCTAssertFalse(accountViewController.logoutButton.isHidden)
}
// More tests
}
Although this is definitely an improvement it still means the author has to know the pattern and not make a mistake.
Having a higher level abstraction removes the chances of messing things up and hopefully reduces the burden on future readers of the code.
11 Feb 2021
Asynchronous tests are hard and recently I found a new rough edge when using XCTestExpectation
.
Take a look at these examples and try to guess the outcome from the following options:
- No tests fail
testA
fails
testB
fails
- Both
testA
and testB
fail
class AsyncTestsTests: XCTestCase {
func testA() throws {
let expectation = self.expectation(description: "async work completed")
asyncFunction { result in
XCTAssertEqual(0, result)
expectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testB() throws {
let expectation = self.expectation(description: "async work completed")
asyncFunction { _ in
expectation.fulfill()
}
waitForExpectations(timeout: 3)
}
}
func asyncFunction(completion: @escaping (Int) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { completion(42) })
}
On Xcode 12 both of these tests are flagged as failing, which was very suprising at first.
The code above follows the same flow that the Apple example code demonstrates here, where there are assertions performed within the async callback.
Anecdotally when checking for blog posts on how to use XCTestExpectation
they seemed to mostly follow the pattern in the Apple docs with a few exceptions that used the proposed solution below.
What’s happening?
If the tests are run without the random order/concurrent options then testA
will be executed before testB
.
testA
fails because the expectation times out so you get a failure with Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "async work completed".
.
The code in testB
shouldn’t actually fail but because of the way the assertion in testA
is performed in the async callback the timing means that the assertion fails whilst testB
is in progress.
This results in Xcode associating the failed assertion with testB
.
The following diagram shows a timeline of the above tests and how the assertion from testA
bleeds into the execution of testB
.

How do I avoid this?
You can avoid this by not performing assertions in the async callback but instead capturing any results and performing the assertions after the wait.
func testA() throws {
let expectation = self.expectation(description: "async work completed")
var capturedResult: Int? = nil
asyncFunction { result in
capturedResult = result
expectation.fulfill()
}
waitForExpectations(timeout: 1)
XCTAssertEqual(0, capturedResult)
}
After making this change the tests now behave as I would expect with testA
failing and testB
passing.
15 Nov 2020
Xcode doesn’t always like it when you swap branches, especially when there are a lot of code changes between the branches.
This can cause all kinds of build caches to become invalid forcing your Xcode to do lots of work.
What’s worse is now your mental resources are concentrated on getting your project compiling again instead of solving interesting problems.
The work I’ve been doing recently has been very bitty with helping colleagues by jumping on screen shares, reviewing PRs or even finding new requirements in my tickets that need work playing before I can continue.
All of these scenarios require me to checkout a different branch, which leads to the problems mentioned above.
Solution
Use git worktree
to get multiple checkouts of a project using a single git repo.
If I have a project called epic-todo-list-app
and a colleague asks for help on their branch named feature/allow-users-to-complete-tasks
, I can run the following command:
git worktree add ../epic-todo-list-app-tmp feature/allow-users-to-complete-tasks
Now on disk I’ll have both projects
ls -1 src/ios
epic-todo-list-app
epic-todo-list-app-tmp
The original project folder will just be my repo in whatever state I was in.
The new project folder epic-todo-list-app-tmp
will have the repo checked out to feature/allow-users-to-complete-tasks
.
At this point I can now open both projects independently in Xcode and they’ll have their own derived data, which means I don’t need to deal with the time sink of getting Xcode to compile the new branch and then my original branch when I switch back.
NB - this technique is really helpful when working with different Xcode versions.
I can have one checkout for the beta and one for the GM version and they won’t destroy each other’s derived data.
Why use git worktree
?
I’ve been using a variation of this technique for a long time now where I was just keeping multiple complete copies (full clones) of the repo.
This isn’t great for a few reasons:
- This is wasteful if you have a repo with a large history as you have two complete copies of the history
- The repos are independent - changes made in one are not reflected in another (unless you push/pull using local paths [not everyone knows you can do this])
- It’s really easy to
git fetch
on one copy and forget to fetch on others so you end up with inconsistent states
git worktree
solves the above problems by the fact that each project folder is backed by the same git repo on disk.
So any operations like fetch
, checkout -b
, merge
…. are all reflected in each place you invoke git
.
Cleanup
Once you’ve finished with the worktree you can easily remove it using the remove
command.
If I wanted to get rid of the worktree I created earlier I’d call
git worktree remove ../epic-todo-list-app-tmp
It’s worth exploring the docs as they aren’t long or too scary looking https://git-scm.com/docs/git-worktree.