There are a quite a few issues here:
Your code can exit before it hits any of the #expect
tests.
You set up the sink
and then emit
the fakeMessages
and then immediately exit. You have no assurances that it will even reach your #expect
tests within the sink
at all. You need to do something to make sure the test doesn’t finish before it has consumed the published values.
Fortunately, async
-await
offers a simple solution. E.g., you might take the sut.$messages
publisher, and then await
its values
. So either:
Use a for await
-in
loop:
for await value in values in sut.$messages.values {
…
}
Or use an iterator:
var iterator = sut.$messages.values.makeAsyncIterator()
let value1 = await iterator.next()
…
let value2 = await iterator.next()
…
// etc
Either way, this is how you can await
a value emitted from the values
asynchronous sequence associated with the sut.$messages
publisher, thereby assuring that the test will not finish before you process the values.
Having modified this to make sure your test does not finish prematurely, the next question is how do you have it timeout if your stubbed service fails to emit the values. You can do this a number of ways, but I tend to use a task group, with one task for the tests and another for a timeout operation. E.g.:
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
let value1 = await iterator.next()
#expect(value1 == [])
…
}
group.addTask {
try await Task.sleep(for: .seconds(1))
throw ChatScreenViewModelTestsError.timedOut
}
try await group.next()
group.cancelAll()
}
Or a more complete example:
@Test
func onAppearShouldReturnInitialMessagesAndStartPolling() async throws {
let mockMessageProvider = MockMessageProvider()
let sut = createSUT(messageProvider: mockMessageProvider)
sut.onAppear()
var iterator = sut.$messages
.buffer(size: 10, prefetch: .keepFull, whenFull: .dropOldest)
.values
.makeAsyncIterator()
Task {
await mockMessageProvider.emit(.success(fakeMessages)) // Emit initial messages
await mockMessageProvider.emit(.success(moreFakeMessages)) // Emit more messages
}
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
let value1 = await iterator.next()
#expect(value1 == [])
let value2 = await iterator.next()
#expect(value2 == fakeMessages)
let value3 = await iterator.next()
#expect(value3 == moreFakeMessages)
}
group.addTask {
try await Task.sleep(for: .seconds(1))
throw ChatScreenViewModelTestsError.timedOut
}
try await group.next()
group.cancelAll()
}
}
}
Your code assumes that you will see two published values:
#expect(sut.messages[0].count == 0)
#expect(sut.messages[1].count > 0)
This is not a valid assumption. A Published.Publisher
does not handle back pressure. If the async sequence published values faster than they could be consumed, your property will drop values (unless you buffer
your publisher, like I have in my example in point 2). This might not be a problem in an app that polls infrequently, but especially in tests where you mock the publishing of values without delay, you can easily end up dropping values.
Your sut.onAppear
starts an asynchronous Task {…}
. But you don’t wait for this and immediately emit
on the mocked service, MockMessageProvider
. This is a race. You have no assurances that poll
has been called before you emit
values. If not, because emit
uses nil
-chaining of continuation?.yield(value)
, that means that emit
might end up doing nothing, as there might not be any continuation
to which values can be yielded yet.
Personally, I would I would decouple the asynchronous sequence from the polling logic. E.g., I would retire AsyncStream
and reach for an AsyncChannel
from the Swift Async Algorithms, which can be instantiated when the message provider is instantiated. And then poll
would not be an asynchronous sequence itself, but rather a routine that starts polling your remote service:
protocol MessageProviderUseCase: Sendable {
var channel: AsyncChannel<MessagePollResult> { get }
func startPolling(interval: TimeInterval)
}
private final class MockMessageProvider: MessageProviderUseCase {
let channel = AsyncChannel<MessagePollResult>()
func startPolling(interval: TimeInterval) {
// This is intentionally blank …
//
// In the actual message provider, the `startPolling` would periodically
// fetch data and then `emit` its results.
}
func emit(_ value: MessagePollResult) async {
await channel.send(value)
}
}
Because the channel
is created when the message provider is created, it doesn't matter the order that startPolling
and emit
are called in our mock implementation.
Some other observations:
Your protocol declares poll
(which returns an asynchronous sequence) as an async
function. But it is not an async
function. Sure, it returns an AsyncStream
, but poll
, itself, is a synchronous function. I would not declare that as an async
function unless you have some compelling reason to do so.
You declared MessageProviderUseCase
protocol to be Sendable
, but MockMessageProvider
is not Sendable
. Your code does not even compile for me. In my mock, (where I have no mutable properties), this is moot, but if you have mutable state, you need to synchronize it (e.g., make it an actor
, isolate the class to a global actor, etc.).
It may be beyond the scope of this question, but I would be a little wary about using a @Published
property for publishing values from an AsyncSequence
. In a messaging app, you might not want to drop values under back-pressure situations. It depends upon your use-case, but note that in the absence of buffer
on your publisher, you can drop values.