It is intentional we haven't included something like this in Swift Testing. It's generally antithetical to how Swift Concurrency is intended to work, and we don't want to encourage anti-patterns in tests.

As a general guideline (asterisk: there are exceptions to every rule!): if something is hard to accomplish with the built-in Swift Concurrency tools, language features, and API, then Swift Testing shouldn't be where the fix or workaround lives because developers will need similar fixes and workarounds in production code too. Therefore, changes here might be better suited for the Swift standard library or the language itself.

We talk a bit about using continuations for this sort of pattern in Go further with Swift Testing. Give it a watch if you haven't yet!

2 Likes

Okay, I see. However, let me ask one more question (let me know if it doesn't fit the current discussion).

It's generally antithetical to how Swift Concurrency is intended to work

I can imagine how this is fair for SDK development or console apps.
But in reality of good old iOS/macOS apps development - we still need an entry point to the Swift Concurrency. In SwiftUI we have a .task {} modifier, which also doesn't cover all usecases. In UIKit we don't even have this. So, no matter how badly we want to adopt Swift Concurrency model, there will always be a place in our app, where we do:

// we store the currentTask in a property to be able to cancel it
// if we close the screen, or we trigger a new task
currentTask = Task {
   // await something here
}

Even if we place it in UIViewController viewDidLoad or in a tap handler of a button, we will still want to have a behavioral test that another layer of our app has been called. In other words: we will have a ViewControllerTests suite where we mock a ViewModel (or an interactor, or a presenter, or a Store, or whatever else we come up with), then SUT is our ViewController, and we call a function of our SUT and assert that ViewModel has been called with the correct parameters.
The same model applies to the situation where we decide that our ViewModel is an entry point to the Swift Concurrency and we test the ViewModel. We want to test that when we call a synchronous function, our mocked repositories/services/etc get called.
In both cases we end up in a situation where we have an unstructured Task that is not being awaited. It's just inevitable. How do we write tests for such situations?
Or do you insist that Swift Concurrency requires us to revisit the common patterns like MVVM/VIP/VIPER and rethink the organization of the layers? I can only think of what folks have suggested in the threads above: make my ViewModel interface function return a Task and await it in my tests. But again, this will leak the concurrency model implementation details to my View layer and will bring inconsistency between modules that have adopted Swift Concurrency and those that have not yet. And this is only possible if the entry point is ViewModel. If the entry point is ViewController, then I can't have viewDidLoad returning a Task that I can later await in my tests.

I hope I've stated my concerns clearly. What do you think?

Note that it still needs to be adjusted, like there is no timeout (which is easy to add) and if closure never fires, test is waiting forever.

I wouldn't say that is a good pattern for general unit-testing; occasionally might be. Making a mistake in expectations much easier that in linear code. And with Swift Concurrency it gets even more obvious. Also, such thinks as unstructured tasks are better to be used rarely and mostly in top-level code as they loose structured benefits such as cancellation propagation. I highly recommend explore ways to write tests that wouldn't need expectations or similar concepts as common solution.

Yeah, I get the notion. But as I mentioned in one message above, we will still need an entry point to the Swift Concurrency in every UIViewController/View. So, imagine we have hundreds of screens and flows in the app. We end up with thousands of tests that need this :)

That’s a workaround, actually. With Swift Concurrency it is generally recommended (and for a good reason) to use structured approach, that is: marking functions async, use async let or TaskGroup. That enables a lot more to be accomplished using it and less get into issues.

Task, which is unstructured part of the concurrency model, is needed still — but should be used at the top level of a call hierarchy, so in views/view-controllers. It fits pretty good with most of the approaches to UI apps.


BTW, the first thing I have noticed in the original post, is that your view model has mixed isolations: that’s one of the things that also has proved itself to be the source of the issues; that’s irrelevant to the main discussion, just a note.

1 Like

I see. Thanks for the discussion. I'll be thinking on how to improve the situation for us and make the best of your tips :)

I don't think I suggested anything like that. What I'm trying to say (and this is opinion, not fact!) is that new tools around concurrency are often better-suited for the Swift standard library because there's nothing about them that's specific to testing.

For instance, "run this function or cancel and throw an error if it takes more than n seconds" sounds like something that could be implemented in the stdlib or a package like swift-async-algorithms. Nothing about such an interface is testing-specific.

You mentioned you may have a task value already:

In which case, awaiting the result of that task is straightforward from Swift Testing. You'd simply write something like:

_ = await currentTask.value

Or combine it with a confirmation:

await confirmation("Widget wobbles") { wobbled in
  let task = Task {
    // ...
    if whatever {
      wobbled()
    }
    // ...
  }
  await task.value
}

I hope that's helpful!

Well... :) this currentTask is a private property of my ViewModel like in the example below:

@Observable
final class ViewModel {
   // ... something something
   private var currentTask: Task<Void, Never>?
   private(set) var viewState = State()

  // ... something something
  
  func start() {
      currentTask = Task {
           let data = await repository.fetchData()
           // ... something something
           viewState = State(data)
           // analytics.track(something)
      }
  }
  // some other functions like search() that also use the `currentTask` below
}

So I cannot use it in the test, the task is private. And making it public for the sake of testing also sounds a bit weird, after all we write production code to solve business problems, not to write tests :)

In the tests, where the ViewModel is SUT I want to mock the repository and analytics, call start function and then assert the viewState, analytics arguments and stuff like this.

I really love Swift Concurrency, and in my company we've managed to adopt it in a way that even with Swift6 language mode and the strictest concurrency checks some of our modules are solid. But it's a huge project with a huge team where not all modules are not on the same level of adoption. We have some way of working and tooling established around testing and architectural choices. That's why I am evaluating Swift Testing as a possible next step for us.
If adopting Swift Testing means that we'll have to rework our architecture, then it complicates things for us as it will take way more time and effort.

And as I mentioned somewhere above: if we move the entry point to the concurrency to ViewController, then we'll still have to test it somehow, just in a different place.
But at this point I am more convinced that View layer should be the entry point to the concurrency. It kinda makes sense, even though it makes it harder to test the View layer, especially if we're talking about SwiftUI, where View is declarative, and the nature of our tests is imperative.

I don’t get this part, actually. If you move Task creation to the controller, you would be able easily test view model, and for controller nothing would change (even if you have some tests for controller part, you had unstructured task before, and you still have it, even with better control).

If view "dumb" enough so that it has only body declaration and calls to model, you don’t need to test it at all. All you can test in that case is only some sort of UI or snapshot tests, as view have no logic in it.

Dunno if it's helpful, but making it internal means you can use @testable import in your test code to access it while still keeping it out of the grubby paws of other modules. :upside_down_face:

Other modules is not a concern :) other modules won't have access to those, because all our ViewModels are internal.
I am more concerned about the currentTask being accessible to View layer where it can be abused. (But it's another discussion, I am not sure we need to dive too deep into architectural choices of different teams and companies here :) some decisions have been made for a reason :D )

if I move tasks management to View layer, then it stops being dumb :( Let's say I work on a Search functionality with debouncing and duplicates skipping. We add task creation into this equation and it's no longer a straight forward task for a dumb view :) while this logic used to fit well into ViewModel

That’s something which you can achieve using structured concurrency. Then view is at max cancels explicitly tasks when it goes out of the screen (as SwiftUI does with .task modifier).