Testing Closure-based asynchronous APIs

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?

2 Likes

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).

Like Sergey, I had a needed to test code which provides a synchronous fire-and-forget interface to an asynchronous process. This kind of API model makes sense for some components.

The solution I came up with was more... brute force: sleep the Task in the confirmation() closure. Sleeps in unit tests aren't great, but it seems to me that the risk of test flakiness is directly equivalent to using XCTest expectations with a timeout, so I'm willing to use them in what are fairly rare cases.

@Test func testSomething() async throws {
        var thing = Something()
        try await confirmation(expectedCount: 1) { called in
            thing.customPost { (req: URLRequest, _: Data) in
                // expectations for the the resultant action
                called()
            }
            thing.triggerFireAndForgetAction()
            try await Task.sleep(for: .seconds(0.01))
        }
    }
1 Like

I have recently used polling in order to wait until a condition is met:

struct MyViewModelTests {
    @Test(.timeLimit(.minutes(1))) // Polling may fail
    @MainActor // MyViewModel is MainActor-isolated
    func something_eventually_happens() async {
        // Given a view model
        let viewModel = MyViewModel(...)
        
        // When some action is performed
        viewModel.performAction()
        
        // Then somerhing eventually happens
        await pollUntil {
            viewModel.someState == 42
        }
    }
    
    /// Convenience method that spins until a condition is met.
    private func pollUntil(condition: @escaping @MainActor () async -> Bool) async {
        await confirmation { confirmation in
            while true {
                if await condition() {
                    confirmation()
                    return
                } else {
                    await Task.yield()
                }
            }
        }
    }
}

Task.yield() is generally the wrong tool for this job as, despite what you might expect, it generally just immediately returns control to the calling task. A tight loop like this one is very prone to causing resource starvation in other tasks.

You could instead use Task.sleep() with a short timeout but the lower bound for timeouts is platform-dependent.

In general, if the original code under test is going to run asynchronously and then return, that's best-modelled by a continuation, not a confirmation. :slightly_smiling_face:

1 Like

Thank you for the advice :+1:

In general, if the original code under test is going to run asynchronously and then return, that's best-modelled by a continuation, not a confirmation. :slightly_smiling_face:

Yes, it's always better when the api under test is trivially testable. Remains the other ones.

2 Likes

Wow, I have done exactly the same today, and was wondering if I should try AsyncSemaphore instead. Why didn't you use your AsyncSemaphore for it?

Oh, yes, AsyncSemaphore is a great testing tool.

The test where I have been using polling is in the GRDB demo app: GRDB.swift/Documentation/DemoApps/GRDBDemo/GRDBDemoTests/PlayerListModelTests.swift at dc03b8a29e2f5d51bd41e0971bba9a9975a29934 · groue/GRDB.swift · GitHub

The tests poll an @Observable object until one of its observable properties has a specific value.

There are a few constraints in a demo app

  1. Swift 6 language mode, obviously - no one should see a warning or an error when enabling all the compiler checks in a demo app.
  2. The demo app must teach something, so it must not be completely trivial.
  3. The demo app must use apis that are available to everybody (hence no dependency).
  4. The code must be approachable for beginners, so that they can write their own code and make it work.
  5. The code must not turn experts off.

I initially thought that I could easily use withObservationTracking instead of polling. But this has been such a fight. In particular, I could never reach points 1 and 4 above.

In these tests, we are not waiting for the setter of an observed property to be called once. The number of times the setter is called is unspecified. No. We wait for the setter to be called with a specific value. I could never use withObservationTracking for this task, in Swift 6 language mode, on a MainActor-isolated object, in a satisfying way.

The plain polling was not suffering from those problems.

2 Likes

I ended up using these two helper functions:


func fulfillment<Condition, SutInput: Sendable>(
    expectation: (@escaping (Condition) -> Void) -> Void,
    whenCalling sutFunction: @escaping @isolated(any) (SutInput) -> Void,
    with input: SutInput
) async {
    let stream = AsyncStream { continuation in
        expectation { _ in
            continuation.yield()
        }
    }
    await sutFunction(input)
    var iterator = stream.makeAsyncIterator()
    await iterator.next()
}

func fulfillment<Condition>(
    expectation: (@escaping (Condition) -> Void) -> Void,
    whenCalling sutFunction: @escaping @isolated(any) () -> Void
) async {
    let stream = AsyncStream { continuation in
        expectation { _ in
            continuation.yield()
        }
    }
    await sutFunction()
    var iterator = stream.makeAsyncIterator()
    await iterator.next()
}

So my tests would look like this:

    @Test
    func sendStart_shouldCallAnalyticsOnce() async {
        let env = Environment()
        let sut = env.makeSUT()
        env.analyticsTracker.trackEventMock.returns()

        await fulfillment(
            expectation: env.analyticsTracker.trackEventMock.whenCalled,
            whenCalling: sut.send,
            with: .start
        )

        #expect(env.analyticsTracker.trackEventMock.calledOnce)
        #expect(sut.viewState.title == "Loaded!")
    }
}

This approach doesn't support timeouts and may not be optimal on a bigger scale. But so far at my company we have decided to stick to the old XCTest. We have also seen posts with people complaining that with Swift Testing it takes much longer to compile tests due to Macros. It's a different matter and does not belong here. Though for us it's also a crucial part, because we have more than 20k unit tests.