Flaky effect cancellation test

Hi,

I'm trying to test cancelling an effect that loads media and sends updates back to the store. Simplified it looks like this:

case .binding(\.mediaLoader.$results):
    let results = state.mediaLoader.results
    state.creationState = .importingMedia(Progress(max: results.count))
    
    return .run { send in
        
        let media = results.enumerated().asyncMap({ (index, result) in
            // Do a lot of stuff
            await send(.updateProgress(index + 1))
        })
        
        await send(.save(media))
    }
    .cancellable(id: MediaCreation.self)

I cancel the effect with:

case .cancelCreation:
    state.creationState = .none
    return .cancel(id: MediaCreation.self)

Now I'm trying to test with:

await store.send(.set(\.mediaLoader.$results, results)) {
    $0.mediaLoader.results = results
    $0.creationState = .importingMedia(Progress(max: 2))
}
await store.send(.cancelCreation) {
    $0.creationState = .none
}

This passes half of the time, half of the time I get an error saying:

Must handle 3 received actions before sending an action

The number of actions I need to handle fluctuates, probably depending on how far the effect gets before getting cancelled.

If I have to handle all the actions initiated by the effect before I can send the cancel action then the test is kind of useless.

Has anyone encountered and solved a similar issue?

How can I properly test cancellation in a not flaky way?

The problem is that there is no way to control the asynchronous context the effect runs in, and so you may think you are cancelling the work at the soonest possible moment, but in reality the effect is getting a little bit of time to do its thing. Stephen and I started a very long thread on this very topic in order to understand the best way to test async code. That may help explain how complex of a situation this is, and how hopefully in the future Swift can provide some more tools.

With that said, there are ways to make your test assert what you want. The first way, and probably the best, has to do with what you have marked as // Do a lot of stuff. In that section of the effect are you reaching out to the environment to perform some asynchronous work? If so, then you can make a mock of that dependency to perform a sleep so that you can simulate it taking a long time, making it possible for the cancellation to happen before any work is processed.

If that is not possible, then the other option that comes to mind is to use a scheduler to sleep in the effect so that you can perform cancellation:

return .run { send in
  self.environment.mainQueue.sleep(for: .seconds(0))
  // ...
}

Then in tests you can use a TestScheduler, which will effectively hold up that effect forever so that you can cancel it. And in all other tests you can use an ImmeduateScheduler.

This second style is more hacky since you are inserting code into production code just to make things testable. The first style is more preferable as it emulates what would actually happen in real life.

2 Likes

Having one of the environment functions sleep for a short amount of time works like a charm.

Thank you so much!