Testing Closure-based asynchronous APIs

For what it's worth, those posts generally predate Swift Testing moving into the toolchain and involve building it and swift-syntax from source code (in addition to your test code.)

I just published a small library with a Swift Testing replacement for XCTestExpectation. Hopefully one day it’ll be Sherlocked, but for now this gets the job done.

2 Likes

here is my submission for this problem which I'm now using:

Thanks to this thread and the great learnings I got from it, I was able to finally use Swift Testing with asynchronous confirmations.

Below you can find the solution I came with, using the OP code as an example, marking with numbers my additions:

@Test
func example() async {

    let (stream, continuation) = AsyncStream<Void>.makeStream() /// 1

    /// Create `sut` here ...
    await confirmation { confirmed in

        env.analytics.trackEventMock.whenCalled { _ in
            confirmed()
            continuation.yield() /// 2
        }

        /// this should be awaited now as we're not on the main actor
        await sut.start()

        var iterator = stream.makeAsyncIterator() /// 3
        await iterator.next() /// 4

        #expect(env.analytics.trackErrorMock.calledOnce)
    }
}

I prefer this approach as opposed to having the other initialiser for AsyncStream as this makes the code more readable.

Also, I prefer this approach as opposed to Task.sleep as this doesn't have to just "waste" a few arbitrary seconds just waiting.

2 Likes

Hello,
I refactored your solution into a utility function:


/// Waits for a confirmation up to the provided `timeout` duration.
/// - Parameters:
///   - message: An optional comment to apply to any issues generated by this function.
///   - expectedCount: The number of times the expected event should occur when body is invoked. The default value of this argument is 1, indicating that the event should occur exactly once. Pass 0 if the event should never occur when body is invoked.
///   - timeout: The maximum amount of time to wait for the confirmation to happen.
///   - isolation: The actor to which body is isolated, if any.
///   - sourceLocation: The source location to which any recorded issues should be attributed.
///   - body: The function to invoke.
/// - Throws: Whatever is thrown by body.
/// - Returns: Whatever is returned by body.
func waitingConfirmation<R>(
    _ message: Comment? = nil,
    expectedCount: Int = 1,
    timeout: Duration = .seconds(1),
    isolation: isolated (any Actor)? = #isolation,
    sourceLocation: SourceLocation = #_sourceLocation,
    _ body: (@Sendable @escaping () -> Void) async throws -> sending R
) async rethrows -> R {
    try await confirmation(
        message,
        expectedCount: expectedCount,
        isolation: isolation,
        sourceLocation: sourceLocation
    ) { confirmation -> R in
        let (stream, continuation) = AsyncStream<Void>.makeStream()

        let result = try await body({
            confirmation.confirm()
            continuation.yield()
        })

        try await withThrowingTaskGroup { group in
            group.addTask {
                var iterator = stream.makeAsyncIterator()
                await iterator.next()
            }

            group.addTask {
                try await Task.sleep(for: timeout)
                continuation.yield()
                throw CancellationError()
            }

            try await group.next()
            group.cancelAll()
        }

        return result
    }
}

2 Likes

Shouldn't the expectedCount argument be the expectedCount parameter instead of 1

1 Like

:see_no_evil_monkey: thank you, fixed!

1 Like

No, I should be the one thanking you, I think you have the solution to my most recent issue before testing asynchronous closures

1 Like

Nice one, @TizianoCoroneo !

1 Like