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)
}
}
}
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 XCTestExpectation
s 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()
}
}