Test state restoration helper

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.

Test isolation with XCTestExpectation

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.

timeline showing async tests with poor isolation

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.

Quick branch switching without confusing Xcode

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.