Writing testable UI related code with modern Swift

Hello, I am trying to understand how I can migrate some of my older Combine /ObservedObject/Quick/Nimble based code to modern async/Observable/Swift Testing code but am running into problems with even very simple situations. I hope I am missing something obvious and would appreciate any insights.

We follow an MVVM pattern for testability's sake. While I know SwiftUI encourages you to put far more code in the view itself, we've found this makes it far harder (or even impossible) to test as thoroughly.

A very stripped down version of one of our existing views looks like this:

protocol BluetoothObject {
    var batteryLife: AnyPublisher<Int, Never> { get }
}

class ViewModel: ObservableObject {
    @Published var batteryPercent = ""
    @Published var criticalBattery = false

    private var cancellables: [AnyCancellable] = []

    init(object: BluetoothObject) {
        object.batteryLife
            .receive(on: DispatchQueue.main)
            .sink { [weak self] num in
                guard let self else { return }
                batteryPercent = "Your battery life is: \(num)%"
                criticalBattery = num < 20
            }
            .store(in: &cancellables)
    }
}

struct BatteryView: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        Text(viewModel.batteryPercent)
            .foregroundStyle(viewModel.criticalBattery ? Color.red : Color.black)
    }
}

There exists an implementation of BluetoothObject which uses Core Bluetooth to actually read battery life updates from a connected peripheral, but that's outside the scope of this question. To test this view model, we have a Quick/Nimble test which looks like this:

class MockBluetoothObject: BluetoothObject {
    let batteryLifeSubject = PassthroughSubject<Int, Never>()
    var batteryLife: AnyPublisher<Int, Never> { batteryLifeSubject.eraseToAnyPublisher() }
}

it("updates based on received battery levels")
    let object = MockBluetoothObject()
    let subject = ViewModel(object: object)

    object.batteryLifeSubject.send(90)
    expect(subject.batteryPercent).toEventually(equal("Your battery life is: 90%"))
    expect(subject.criticalBattery).toEventually(beFalse())

    object.batteryLifeSubject.send(10)
    expect(subject.batteryPercent).toEventually(equal("Your battery life is: 10%"))
    expect(subject.criticalBattery).toEventually(beTrue())
}

My attempt to rewrite this code in more modern Swift looks like this:

protocol BluetoothObject {
    var batteryLife: any AsyncSequence<Int, Never> { get }
}

@Observable @MainActor class ViewModel {
    var batteryPercent = ""
    var criticalBattery = false

    init(object: BluetoothObject) {
        Task { [weak self] in
            for await num in object.batteryLife {
                if let self {
                    batteryPercent = "Your battery life is: \(num)%"
                    criticalBattery = num < 20
                } else {
                    break
                }
            }
        }
    }
}

struct BatteryView: View {
    var viewModel: ViewModel

    var body: some View {
        Text(viewModel.batteryPercent)
            .foregroundStyle(viewModel.criticalBattery ? Color.red : Color.black)
    }
}

I cannot find a way to write a test equivalent to the earlier test using Swift Testing. Because Swift Testing does not have expectations or polling, it doesn't seem possible. This looks close, but will fail because there is no time between the yield and the #expect for the view model to actually update its properties.

@MainActor
struct ViewModelTests {
    @Test func test() {
        let object = MockBluetoothObject()
        let subject = ViewModel(object: object)

        object.batteryLifePair.continuation.yield(90)
        #expect(subject.batteryPercent == "Your battery life is: 90%")
        #expect(subject.criticalBattery == false)

        object.batteryLifePair.continuation.yield(10)
        #expect(subject.batteryPercent == "Your battery life is: 10%")
        #expect(subject.criticalBattery == true)
    }
}

Inserting Task.sleep between yields and expects allows tests to pass, but at that point I'm essentially just implementing a worse version of polling than I had before with Nimble.

Is there a good way to write this kind of test with modern Swift and Swift Testing? Or do I need to continue using Nimble if I want to avoid writing a poor version of polling?

3 Likes

This library from PointFree has a tool, withMainSerialExecutor, that runs async code more deterministically. I've also seen this warned against by folks here since it's technically a very different runtime environment, but I think in this case, and when used sparingly, it can be very helpful. Essentially your test would look like this, I think:

@MainActor
struct ViewModelTests {
  @Test func test() {
    await withMainSerialExecutor {
      let object = MockBluetoothObject()
      let subject = ViewModel(object: object)
      await Task.yield()

      object.batteryLifePair.continuation.yield(90)
      await Task.yield()
      #expect(subject.batteryPercent == "Your battery life is: 90%")
      #expect(subject.criticalBattery == false)

      object.batteryLifePair.continuation.yield(10)
      await Task.yield()
      #expect(subject.batteryPercent == "Your battery life is: 10%")
      #expect(subject.criticalBattery == true)
    }
  }
}

There are also other useful tools that PointFree makes, like swift-clocks.

Thanks for that tip! It looks like that library could definitely help, but it does sounds like based on their documentation that all suites would need to be .serialized and parallel suite testing would also have to be disabled since "under the hood it relies on a global, mutable variable in the Swift runtime to do its job, and there are no scoping guarantees should this mutable variable change during the operation."

This looks like it could be a functional option, but unfortunately would take away one of the main benefits drawing us to Swift Testing -- highly parallel, fast tests.

The PointFree library you link to above links in turn to this three year old thread Reliably testing code that adopts Swift Concurrency? which speaks to similar testing difficulties. I'm hoping that three years later and with a brand new testing library these issues are more fully solved (this issue doesn't exist with Kotlin coroutines as far as I know), but if not then at least this thread can help others in the same boat see that these issues still exist in 2025 as I was wondering. Honestly the more I read about Swift Testing and testing async await in general makes me think it can't support the real-world situations most people deal with (e.g. infinite async sequences, objects encapsulating Tasks, etc.). It's undoubtedly great for simple or toy asynchronous situations though. We may stick with Combine and Nimble for now

1 Like

As a general rule, polling is not recommended in Swift. Instead, see if you can leverage the features of Swift concurrency to suspend the test until the data you need is available. For example, if the code you're testing fires a callback when it finishes asynchronously, you can use withCheckedContinuation to convert the asynchronous-but-not-Swift-concurrency-aware operation into one that uses await.

Since your code is currently using Combine, consider refactoring it to use Swift concurrency's tools instead. If that's not possible, consider exposing the Combine publisher so the test can then await it.

I haven't used Combine in a while but I recall @Published properties, which use property wrappers, expose interfaces for generating sinks. Something like:

@Test func criticalBatteryEventually() async {
  let viewModel = ...
  // ...
  let isCritical = await viewModel.$criticalBattery.values.contains { $0 == true }
  #expect(isCritical)
}

(I haven't attempted to compile that, it's just off the top of my head.)

Edit: This is what I get for skimming. :melting_face: When you're using @Observable, you can use withObservationTracking(_:onChange:) in your test to observe changes:

@MainActor @Test func criticalBatteryEventually() {
  let viewModel = ...
  withObservationTracking {
    // ...
  } onChange: {
    #expect(viewModel. criticalBattery)
  }
}

(Again, I haven't attempted to compile it.)

2 Likes

I cannot find a way to write a test equivalent to the earlier test using Swift Testing. Because Swift Testing does not have expectations or polling, it doesn't seem possible.

swift-testing doesn't support polling but I believe the equivalent to XCTest expectations would be the confirmation API.

The actual use cases for confirmation() are fairly narrow and the API is primarily meant for testing "event handler"-style callbacks. Since there is no event handler here (that I can see, at least?) confirmations wouldn't be useful.

3 Likes

We ran into something similar when testing for the ImmutableData [full discolsure: self promotion] project. What we were looking for was something like an AsyncSequence with backpressure that also blocked on yield. The AsyncAlgorithms.AsyncChannel came close… but yield was async backpressure was applied after the for await was already started.

We ended up building our own AsyncSequence just for testing and then injecting that AsyncSequenceTestDouble at compile time with dependency injection in our tests. You can see for yourself how we use this AsyncSequenceTestDouble in our ImmutableData test code. This might give you some ideas how something similar might be used in your own tests.

Unfortunately… the public API on Observation is not willSet (which delivers the new value) or didSet (which took place after the value was set). The Observation API available outside Apple just tells you "some change is going to happen"… but engineers then have to work around that with an additional dispatch main or something to actually see the new value.

1 Like

Thanks for the response! Unfortunately withObservationTracking cannot be used because viewModel is isolated to the main actor, leading to the error "Main actor-isolated property 'criticalBattery' can not be referenced from a Sendable closure" when compiling that code.

I understand that in theory Swift Testing works best if you always "surface" the asynchronicity of a function to the consumer of a function. I.e. it's easy to test

func asyncFunc() async {
    await sideEffect()
}
@Test func test() async {
    await asyncFunc()
    #expect(sideEffectDone)
}

while its very hard to test

func internallyAsyncFunc() {
    Task {
       await sideEffect()
    }
}
@Test func test() {
    internallyAsyncFunc()
   // Impossible or very hard here to check if side effect completed without polling / expectations
}

That's fine for a lot of APIs -- you often do want to surface the asynchronicity through the API. Many other times though, you do not. My example here of consuming an infinite async sequence is one example. The consumption of an infinite sequence never completes, so there's no way to "await" for it to be done before checking for a side effect. Another example that I've seen is a state machine which queues all function calls, but doesn't want to surface this to consumers of the state machine, so every function looks like the internallyAsyncFunc above.

1 Like

Thanks for the link, I'll take a look at this and see if it could help with my issue!

1 Like

Our POV here was this isn't necessarily a swift-testing issue… swift-testing can work fine with AsyncSequence. What you might be looking for is a concrete AsyncSequence type just for testing that behaves in a way that makes tests deterministic and predictable.

Your production code should continue depending on AsyncStream or another "production" quality type.

Here's a pattern from ImmutableData that might work for you:

extension Listener {
  fileprivate func onChange(_ onChange: @escaping @Sendable () -> Void) {
    withObservationTracking {
      let _ = self.output
    } onChange: {
      onChange()
    }
  }
}

The Listener class is MainActor.

We can then define an actor suite without MainActor:

@Suite final actor ListenerTests {
  
}

Here is an example of a test:

extension ListenerTests {
  @Test func outputDidChange() async throws {
    let listener = ...
    let store = ...
    await confirmation(expectedCount: 1) { onChange in
      await listener.onChange(onChange.confirm)
      await store.dispatch(action: ActionTestDouble())
    }
    
    #expect(try await listener.output...
  }
}

The store.dispatch fires an event that should affect a change on the output (which is observed). This then satisfies the onChange from confirmation.

If @vanvoorden 's suggestion works for you, that looks like a valid approach. :+1:

Personally, I've found that sometimes using polling/a busy loop isn't such a bad solution. :face_with_peeking_eye:

1 Like

Just to expand on that… the AsyncIteratorProtocol does tell us that passing nil from next() is the end of the sequence.

You should also be able to cancel the Task that wrapped the for await. That should usually also cancel the sequence (but you might have to hook up some of that code yourself depending on where the continuation is stored).

This is not always possible, especially when you want to test an async endpoint that is stateful over time, or where you need to wiggle your way in between a method's suspension points, as I believe is the case here, and is a very common case with observable models in SwiftUI.

The three year old thread linked above has an example that I believe is still not possible to reliably test in Swift today:

1 Like

Just to expand on that… the AsyncIteratorProtocol does tell us that passing nil from next() is the end of the sequence.

You should also be able to cancel the Task that wrapped the for await. That should usually also cancel the sequence (but you might have to hook up some of that code yourself depending on where the continuation is stored).

@vanvoorden if you pass nil to an AsyncIteratorProtocol yes it terminates, but by definition an infinite sequence is one which never yields nil and never terminates which is what I'm working with in my original question. I don't want to cancel the Task either, as I am testing that passing different values through the pipeline results in the view model being updated as expected. Cancelling a task would stop the view model from updating altogether.

This is not always possible, especially when you want to test an async endpoint that is stateful over time, or where you need to wiggle your way in between a method's suspension points, as I believe is the case here, and is a very common case with observable models in SwiftUI.

Thanks @stephencelis that's a perfect way of describing my issue.

1 Like

If I understand the original question correctly… it sounds like you are looking for a way to both synchronously pass a new value to a continuation AsyncIterator (which is tied to an AsyncSequence) while also blocking on the consumption of that value (backpressure)? Does that sound correct?

If the yield(90) and yield(10) both blocked on consumption of the value then next call to batteryPercent and criticalBattery should have the correct values.

What are you using for an AsyncSequence in your production code? Is this built on AsyncStream?

If you don't need the yield to be sync you might even be able to try AsyncChannel directly in this MockBluetoothObject.

Actually IIRC we saw trouble with AsyncChannel in ImmutableData because AsyncChannel blocks on consumption correct after a for await already started. We saw race conditions in our test code where sometimes we were attempting to send a new value "before" the for await was called. The custom AsyncSequenceTestDouble blocked on consumption including if send happened before the for await. This can of course lead to deadlocked and frozen tests if you call send without any for await being called… so please be careful if you try this approach.

Thanks for AsyncChannel callout -- after experimenting with it, it's still flaking for me I believe because with this updated code:

class MockBluetoothObject: BluetoothObject {
    let batteryLifeChannel = AsyncChannel<Int>()
    var batteryLife: any AsyncSequence<Int, Never> { batteryLifeChannel }
}
@MainActor
struct ViewModelTests {
    @Test func test() async throws {
        let object = MockBluetoothObject()
        let subject = ViewModel(object: object)

        await object.batteryLifeChannel.send(90)
        #expect(subject.batteryPercent == "Your battery life is: 90%")
        #expect(subject.criticalBattery == false)

        await object.batteryLifeChannel.send(10)
        #expect(subject.batteryPercent == "Your battery life is: 10%")
        #expect(subject.criticalBattery == true)
    }
}

The back pressure is only applied until the object is "consumed" by the for loop, but not necessarily before the entire loop body executes. As such, sometimes the value is sent through the channel, received by the for await loop, then the backpressure is lifted and the test expectations run but the body of the for await loop which actually updates the properties has not executed yet. Writing out the execution order:

struct ViewModelTests {
    @Test func test() async throws {
        let object = MockBluetoothObject()
        let subject = ViewModel(object: object)

       // 1. Send is called
        await object.batteryLifeChannel.send(90)
       // 3. Because the value was consumed, execution continues here
        #expect(subject.batteryPercent == "Your battery life is: 90%")
        #expect(subject.criticalBattery == false)
    }
}

...

init(object: BluetoothObject) {
    Task { [weak self] in
        // 2. 90 is received, so num=90 but the loop body hasn't actually executed yet and properties have not updated
        for await num in object.batteryLife {
            // 4. After the test expectations have failed, we now execute the for body with num=90
            if let self {
                batteryPercent = "Your battery life is: \(num)%"
                criticalBattery = num < 20
            } else {
                break
            }
        }
    }
}

I'm not sure if this is a correct explanation of why the tests still don't work, but they still fail every time.

To answer your other question about what is actually being used in the real implementation of BluetoothObject, we are using a CurrentValueSubject and converting it to an async sequence with .values since as far as I can tell, async sequences also don't support sharing/multicasting which is important in this case of broadcasting battery life of a peripheral throughout the app

1 Like

Ahh… yes. You are correct. This was the reason we didn't end up with AsyncChannel in our ImmutableData tests. You are correct. The "backpressure" is only applied after the for await already started.

Could we start with a test that assumes "one publisher" and "one subscriber"?

// https://github.com/Swift-ImmutableData/ImmutableData

import AsyncSequenceTestUtils

class MockBluetoothObject: BluetoothObject {
  let batteryLife = AsyncSequenceTestDouble<Int>()
}

You should at that point be good to write a test that passes deterministically without race conditions:

struct ViewModelTests {
  @Test func test() async throws {
    let object = MockBluetoothObject()
    let subject = ViewModel(object: object)
    
    await object.batteryLife.iterator.resume(returning: 90)
    #expect(subject.batteryPercent == "Your battery life is: 90%")
    #expect(subject.criticalBattery == false)
  }
}

If you know that your test code is going to lead to the batteryLife sequence values being called from multiple places then that should be possible with AsyncSequenceTestDouble. I guess at that point MockBluetoothObject would actually return a new AsyncSequenceTestDouble each time. Then MockBluetoothObject can define a send function that passes the new value to all registered sequences.

BTW the ImmutableData currently depends on macOS 14 because of our dependencies on Observable… but we do have a WIP repo (shipping soon) that deploys back to 10.15 for supporting legacy platforms. If you are blocked on ImmutableData in your own repo the actual code in AsyncSequenceTestUtils has no dependencies on Observable or anything other than normal Swift Concurrency. Copying the AsyncSequenceTestDouble.swift file directly in your repo should work if you need to support older platform versions.

It looks like AsyncSequenceTestDouble is currently blocked on Xcode 16.3 and Swift 6.1 because of a potential issue in associated type inference. The workaround from GitHub unblocks building on 6.1. I'm going to push a new diff to main and after that soaks I will release a new version of the ImmutableData infra and AsyncSequenceTestDouble.

1 Like