How do you migrate an XCTest using completion handlers to Swift Testing?
I’ve been slowly migrating XCTest cases to Swift Testing. One situation that slowed me down was figuring out the best way to test code that relies on completion handlers.
XCTest Expectations
I use a custom subclass of NSPersistentContainer
when working with Core Data. My subclass performs some extra configuration when loading the store but otherwise works the same way. The loadPersistentStores
method calls a completion handler for each store it loads:
container.loadPersistentStores { description, error
if let error { // handle error }}
}
I configure my container to load the store, and call the completion handler, asynchronously. I tested the operation with an XCTest like this:
import XCTest
class StoreTests: XCTestCase {
private var container: CoreDataContainer!
override func setupWithError() {
container = CoreDataContainer(...)
}
@MainActor func testLoadStore() {
let expect = expectation(description: "Store loaded")
container.loadPersistentStores { description, error in
XCTAssertNil(error)
expect.fulfill()
}
waitForExpectations(timeout: 2, handler: nil)
XCTAssertTrue(container.isStoreLoaded)
}
}
The XCTest framework provides an expectation mechanism for testing asynchronous completion blocks like this. You first create an expectation:
let expect = expectation(description: "Store loaded")
Then in the completion handler we mark the expectation as fulfilled:
expect.fulfill()
Then we can wait for the expectation to fulfill. If we don’t fulfill the expectation within the timeout the test fails:
waitForExpectations(timeout: 2, handler: nil)
Swift Continuations
The Swift Testing framework doesn’t use expectations. It does have something similar called confirmations, but they don’t help when testing completion handler code. Donny Wals has a helpful explanation. In brief, the confirmation doesn’t block the caller so we have no way to wait until we have fulfilled the confirmation (expectation).
Apple’s guide to migrating a test from XCTest gives more specific guidance on testing completion handlers:
For a function that takes a completion handler but which doesn’t use await, a Swift continuation can be used to convert the call into an async-compatible one.
So we want to convert our test to be async using a Swift continuation. This doesn’t need any Swift Testing features. We can even rewrite our XCTest method to be asynchronous using a continuation and remove the expectation:
@MainActor func testLoadStore() async {
await withCheckedContinuation{ continuation in
container.loadPersistentStores { description, error in
XCTAssertNil(error)
continuation.resume()
}
}
XCTAssertTrue(container.isStoreLoaded)
}
This approach does have a couple of downsides:
- Failure to call the continuation method hangs the test so you may want to configure a shorter test execution time allowance in a test plan.
- You must call the continuation once, and only once. That would be a problem if my container was loading multiple stores.
Swift Testing
Migrating this new XCTestCase to Swift Testing now follows a familiar path, replacing the XCTest assertions with #expect:
import Testing
@MainActor struct StoreTests {
private let container: CoreDataContainer
init() {
container = ...
}
@Test func loadStore() async {
await withCheckedContinuation { continuation in
container.loadPersistentStores { description, error in
#expect(error == nil)
continuation.resume()
}
}
#expect(container.isStoreLoaded)
}
}