swiftxctestswift-concurrency

Wait for Task to finish in XCTest without using Task.sleep


I have following case.

NotMyProtocol is from external library. It is not suitable to work with Swift Concurrency so I though some way to implement it and I will pass some proxy into it to forward invocations in some desired StreamActor context. So in execute method I just put everything into the Task that run in StreamActor context and execute proxy async method. Code works but now I just want to make some test to check if execute on MyProtocolImpl is invoking execute on AsyncMyProtocol in correct StreamActor domain. I just want to make sure if this async execute method invoked only once. I wonder how to wait for switching context from nonisolated to StreamActor and wait for execute to finish so I can check how many messages was sent to asyncProxy. I can use Task.sleep(for:) and tests are passing but I would like to not use it because it smells for me. Is there any better way how to design those test for such system under test?

import XCTest

@globalActor public actor StreamActor: GlobalActor {
    public static let shared = StreamActor()
    private init() {}
}

// This protocol comes from external library and I can not change its definition
protocol NotMyProtocol: AnyObject {
    func execute()
}

@StreamActor
protocol AsyncMyProtocol: Sendable {
    func execute() async
}

// This is non isolated class conforming to the protocol from external library
// It wraps asyncProxy inside and forwards messages to it but in StreamActor context
class MyProtocolImpl: NotMyProtocol {
    private let asyncProxy: AsyncMyProtocol

    init(asyncProxy: AsyncMyProtocol) {
        self.asyncProxy = asyncProxy
    }

    func execute() {
        Task { @StreamActor [asyncProxy] in
            await asyncProxy.execute()
        }
    }
}

final class AsyncMyProtocolImpl: AsyncMyProtocol {
    func execute() async {
        // async logic
    }
}

class MyProtocolImplTests: XCTestCase {
    func test_execute_callsExecuteOnAsnycProxyInStreamActorContext() async {
        let spy = AsyncMyProtocolSpy()
        let sut = MyProtocolImpl(asyncProxy: spy)

        sut.execute()

        // Here how to wait for sut.execute to finish with all its Tasks?
        // I don't want to use Task.sleep(for: ) because it is a code smell
        // How to wait so spy will receive execute message on StreamActor context

        let receivedMessages = await spy.receivedMessages
        XCTAssertEqual(receivedMessages, [.execute]) // <- Here received messages are empty
    }

    private class AsyncMyProtocolSpy: AsyncMyProtocol {
        enum Messages {
            case execute
        }

        var receivedMessages: [Messages] = []

        func execute() async {
            receivedMessages.append(.execute)
        }
    }
}

Solution

  • Do not "fire-and-forget" those tasks created by execute. There is no magic. Keep references to them and wait for them in the test.

    var tasks: [Task<Void, Never>] = []
    
    func execute() {
        let task = Task { [asyncProxy] in
            await asyncProxy.execute()
        }
        tasks.append(task)
    }
    
    func waitForAll() async {
        for task in tasks {
            _ = await task.value
        }
    }
    

    Then in your test you would do await sut.waitForAll() before asserting.


    Note that XCTestExpectations are usually used to assert asynchronous things. Consider doing this instead:

    func test_execute_callsExecuteOnAsnycProxyInStreamActorContext() async {
        let expectations = [expectation(description: "execute")]
        // do not pass an array literal directly into AsyncMyProtocolSpy
        let spy = AsyncMyProtocolSpy(expectations: expectations)
        let sut = MyProtocolImpl(asyncProxy: spy)
    
        sut.execute()
        
        await fulfillment(of: expectations, timeout: 1, enforceOrder: true)
    }
    
    private class AsyncMyProtocolSpy: AsyncMyProtocol {
    
        // by using an array of expectations here,
        // we sort of recreates something similar to comparing the array of 'Message's
        var expectations: [XCTestExpectation]
        
        nonisolated init(expectations: [XCTestExpectation]) {
            self.expectations = expectations
        }
        
        func execute() async {
            let next = expectations.removeFirst()
            XCTAssertEqual(next.expectationDescription, "execute")
            next.fulfill()
        }
    }