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?