I’m having a hard time figuring out how to test application code as it adopts async
-await
and begins to be scheduled concurrently. I’ll try to show a simple example.
Here’s a basic SwiftUI application that tracks how many times a user has taken a screen shot. It has a view model with an endpoint that spins off some async work, and then the view calls this endpoint when it first appears:
class ViewModel: ObservableObject {
@Published var count = 0
@MainActor
func task() async {
let screenshots = NotificationCenter.default.notifications(
named: UIApplication.userDidTakeScreenshotNotification
)
for await _ in screenshots {
self.count += 1
}
}
}
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
Text("\(viewModel.count) screenshots have been taken")
.task { await viewModel.task() }
}
}
I’d like to write a test that simulates loading this screen, taking a screen shot, and asserting that the view model’s data has incremented. I’ve come up with the following:
@MainActor
func testBasics() async throws {
let vm = ViewModel()
let task = Task { await vm.task() }
XCTAssertEqual(vm.count, 0)
// Give the task an opportunity to start executing its work.
await Task.yield()
// Simulate a screen shot being taken.
NotificationCenter.default.post(
name: UIApplication.userDidTakeScreenshotNotification, object: nil
)
// Give the task an opportunity to update the view model.
await Task.yield()
XCTAssertEqual(vm.count, 1)
task.cancel()
await task.value
}
The calls to Task.yield
feel wrong to me, but I don’t know of an alternative.
The real problem with this code, though, is that the test occasionally fails! You can use Xcode’s “run repeatedly” feature 1,000 times and will almost always get a failure or two. From what I can gather, this is because there’s no guarantee that Task.yield
will suspend long enough for the task to do its work.
I can sprinkle in more Task.yield
s and the test fails less often:
// REALLY give the task an opportunity to start executing its work.
await Task.yield()
await Task.yield()
await Task.yield()
await Task.yield()
await Task.yield()
Or we could try adding explicit Task.sleep
s, instead.
But these obviously can’t be the recommended ways of handling the problem. Both solutions are flakey and could fail at any time, and sleep
ing slows tests down unnecessarily.
So how are we supposed to test async code like this?
I’ve pushed the project if anyone wants to mess around with it: GitHub - stephencelis/ScreenshotWatcher: A demonstration of testing uncertainty in Swift Concurrency