mokacoding

unit and acceptance testing, automation, productivity

XCTNSPredicateExpectation is slow, and what to do about it

TL;DR XCTNSPredicateExpectation requires a timeout of at least 1.1 seconds or it will fail regardless of whether the behavior under test occurred. To avoid slowing down your tests, use Nimble's toEventually instead.


Asynchronous code comes in various forms. Callbacks, notifications, delegate calls, and most recently async/await. Worry not, there's a way to test each of those.

When the asynchronous behavior you want to test changes the state of the system as a result of a method call that doesn't come with a callback to hook into, XCTest offers the XCTNSPredicateExpectation to test it.

// Arrange
// ...
let predicate = NSPredicate { _, _ in
  // some logic returning true if the expectation is met
}
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: .none)

// Act
//
// call method on System Under Test that result in async behavior

// Assert
wait(for: [expectation], timeout: <# a timeout #>)

This approach comes in handy to verify how a @Published property changing as a result of a remote API call, like we see in Chapter 8 of Test-Driven Development In Swift.

Let's put the pattern into practice with a "concrete" example.

Say we have an AsyncWorkPerformer object with a toggleAsynchronously(after:) method which changes the value of its flag property from false to true. We can test it using XCTNSPredicateExpectation like this:

// Arrange
let sut = AsyncWorkPerformer()
let expectation = XCTNSPredicateExpectation(
    predicate: NSPredicate { _, _ in sut.flag },
    object: .none
)

// Act
sut.toggleAsynchronously(after: 0.1)

// Assert
wait(for: [expectation], timeout: 2)

In the book, I recommended using 2 seconds for the timeout because, in my experience, waiting for 1 second or less with XCTNSPredicateExpecation can sometimes result in unexpected timeouts.

I decided to dig deeper and built a benchmark test to discover exactly where the timeout safety threshold is.

With asynchronous code executing after a 0.1s delay:

  • Waiting for 0.9 seconds or less always results in a timeout
  • Waiting for 1 second can sometime result in a timeout
  • Waiting for 1.1 seconds and above always succeeds

Details on the approach I used are at the end of the post.

Why is XCTNSPredicateExpectation slow?

Apple engineer Stuart Montgomery explains why this API is less performant than others in the 2018 WWDC talk Testing Tips & Tricks.

Images from the 2018 WWDC talk Testing Tips and Tricks

Simply put, XCTNSPredicateExpectation is slower because it uses polling as opposed to more direct callback mechanisms—and with a long sampling interval, too.

What to do when XCTNSPredicateExpectation slowing your tests

Sometimes the best way to avoid a problem is not to have a problem in the first place. To address the XCTNSPredicateExpectation performance overhead, don't use XCTNSPredicateExpectation.

Use Nimble instead.

I've talked about Nimble already. I love it. It's an xSpec-style matcher library that provides a much more refined API than XCTest and also makes your suite read more like English.

You can install Nimble via your favorite dependency management system and start using it straightaway.

Nimble comes with a toEventually API that allows waiting for a matcher to succeed. Let's use it to make our test from above faster and clearer.

// Arrange
let sut = AsyncWorkPerformer()

// Act
sut.toggleAsynchronously(after: 0.1)

// Assert
expect(sut.flag).toEventually(beTrue())

Using Nimble, your tests won't have to wait for 1+ seconds because toEventually checks its condition every 10 milliseconds by default.


Do yourself, your team, and your CI a favor and rewrite all your tests using XCTNSPredicateExpectation with Nimble's toEventually.

Adding this extra dependency is a well worth cost to pay for the improve in test performance, even if you don't plan to use Nimble's APIs anywhere else in your suite.

Appendix: Method used

To test how XCTNSPredicateExpectation performs, I built a dummy object performing an asynchronous property toggle:

class AsyncWorkPerformer {

    private(set) var flag = false

    func toggleAsynchronously(after interval: TimeInterval) {
        DispatchQueue.main.asyncAfter(deadline: .now() + interval) { [weak self] in
            self?.flag.toggle()
        }
    }
}

I then wrote tests for this behavior like the one shown at the start of the post using different timeout values.

I used the XCTExpectFailure API to mark all the failures in tests with short wait times as expected. This achieves two goals: Keeps the test report neat without a barrage of red crosses and, if Apple will improved XCTNSPredicateExpectation in the future making those examples pass, the test suite will alert me of it.

To make the benchmark more thorough, I used the "Up Until Maximum Repetitions" Test Repetition mode introduced in Xcode 13 to repeat each test three time, regardless of their result.

Here's a sample of the inconsistent results one gets when using a 1 second interval:

Example 1: Fail. Success. Success.

1 second test failed, succeeded, succeeded

Example 2: Success. Fail. Fail.

1 second test succeeded, failed, failed

Example 3: Success. Fail. Success.

1 second test succeeded, failed, succeeded

I run the benchmark on my late 2019 16" MacBook Pro as well as on the GitHub Actions CI. The results were the same, which is unsurprising because the XCTNSPredicateExpectation performance limitations are due to its design not to how resource intensive it is.

Open Graph Image by Photo by Dušan veverkolog on Unsplash

Want more of these posts?

Subscribe to receive new posts in your inbox.