Reliably testing code that adopts Swift Concurrency?

Wondering if there is any consensus in testing sync method that creates a Task ?

If I have

class MyClass { 
  func foo() {
     Task { 
        // call async method here
     }
  }
}

How do I write XCTest for foo asserting that the async method was called or something else happened in async manner.

This is one of the numerous downsides of using Task.init and Task.detached. IMO this code should be rewritten to avoid unstructured tasks and to use structured concurrency instead, either async let or task groups. This not only makes it more testable, but allows you to handle cancellation and error handling in a much better way. Please refer to this WWDC video for details: Beyond the basics of structured concurrency - WWDC23 - Videos - Apple Developer.

4 Likes

This doesn't, and can't, match the semantics of the original example, nor do I see how using TaskGroups or async let makes the code more testable. You can't control structured concurrency any better than Task itself. And the vast majority of Task usage occurs because users need to start async work from a sync context. So suggesting users refactor to use async methods doesn't help, as the original requirement hasn't been solved.

3 Likes

I think Max's suggestion is to make MyClass.foo() async and push the responsibility of spinning up a task on the caller. This is how you can make your model objects more testable, by spinning up Tasks from the less testable areas, like SwiftUI views:

@Observable
class Model {
  …
  func fetchButtonTapped() async {
    …
  }
}

In the view you spin off the task when the button is tapped:

Button("Fetch") {
  Task { await model.fetchButtonTapped() }
}

But when you test against the model you can await the result:

func testFetch() async {
  let model = Model(…)
  await model.fetchButtonTapped()
  // make assertions
}
6 Likes

I don't see that implication, but I can see the slight improvement in your example. Unfortunately it assumes the user had no reason for foo() to be synchronous, which may nor may not be true. And while making the method itself async allows us to await its completion, it's not a solution for awaiting all async work that may be started by the method, which is ultimately what's desired.

What I've opted to do in this sort of situation is hold a reference to the task in the view model. This has the additional upside that it can then be used to disable buttons or show loading indicators if desired.

@MainActor
final class MyViewModel: ObservableObject {
  @Published private var loadingTask: Task<Void, Never>?

  var fetchButtonDisabled: Bool { 
    loadingTask != nil
  }

  func fetchButtonTapped() {
    loadingTask = Task {
      // perform async work, catch any errors, etc
      loadingTask = nil
    }
  }
}

struct MyView: View {
  @StateObject var viewModel: MyViewModel

  var body: some View {
    Button("Fetch") { viewModel.fetchButtonTapped() }
      .disabled(viewModel.fetchButtonDisabled)
  }
}

For testing, you could also expose additional methods that wait for the work to be done to make that easier.

extension MyViewModel {
  func waitForLoadingTask() async {
    await loadingTask?.value
  }
}

Honestly, IMO, I think that it may have been a mistake to make Task's initializer @discardableResult...

1 Like

I think the answer may be is that if foo() needs to be synchronous and needs to kick off async work, that should be the only thing it does. Something like:

func foo() {
    Task {
        await foo()
    }
}
func foo() async {
    // actually do things
}

If your unstructured wrappers are always just trivial wrappers then you can get away with not testing them, and only test the functions which do allow awaiting their completion.

Thanks for the explanations. Eventually there will inevitably be situations where users need to start async work from a sync context. - I should have a master-parent-boss Task that all async functions are executed in right, or I just have to start async work from sync function - who creates the Task instance? Does that mean then I have to make the semantic inference that a sync foo should only create a Task which executes the given async function? Is this a Swift Concurrency WIP-like thing or I am missing additional understanding?