Migrating ObservableObject and @Published Tests to Swift’s New @Observable Macro

396 views Asked by At

I have just migrated from using ObservableObject and the @Published property wrapper to the new @Observable macro in Swift. I am wondering how I can rewrite these kind of tests. Please keep in mind that my tests cover arbitrarily complex cases with asynchronous behavior, and this is just an oversimplified example.

func testVoiceListUpdates() {
    let initialExpectation = XCTestExpectation(description: "Voice list should be empty initially")
    let firstExpectation = XCTestExpectation(description: "First voice should be added")
    let secondExpectation = XCTestExpectation(description: "Second voice should be added")

    let viewModel = MyViewModel()

    let firstVoice = Voice.fixture()
    let secondVoice = Voice.fixture()

    viewModel.$voiceList
        .sink { newValue in
            switch newValue.count {
            case 0:
                initialExpectation.fulfill()
            case 1:
                if newValue.first == firstVoice {
                    firstExpectation.fulfill()
                    viewModel.addVoice(secondVoice)
                }
            case 2:
                if newValue.last == secondVoice {
                    secondExpectation.fulfill()
                }
            default:
                break
            }
        }
        .store(in: &cancellables)

    viewModel.addVoice(firstVoice)
    
    wait(for: [initialExpectation, firstExpectation, secondExpectation], timeout: 1)
}

The point is that in the MVVM architecture all of the logic is in the ViewModel, and all of the visible behavior can be fully tested by just testing the ViewModels. How are we supposed to do this with the new @Observable macro? Any suggestions and best practices would be appreciated.

2

There are 2 answers

2
Felix Lunzenfichter On

Introducing a Dedicated Testing CurrentValueSubject when using @Observable

A simple solution would be to introduce a testVoiceListSubject in our ViewModel to observe changes in the voiceList property. Below is how we can adjust our ViewModel and test class accordingly:

  1. Modify our ViewModel:
@Observable
class MyViewModel {
    private(set) var testVoiceListSubject = CurrentValueSubject<[Voice], Never>([])
    
    var voiceList: [Voice] = [] {
        didSet {
            testVoiceListSubject.send(voiceList)
        }
    }
    
    func addVoice(_ voice: Voice) {
        voiceList.append(voice)
    }
}
  1. Update Our Test Method:
func testVoiceListUpdates() {
    let initialExpectation = XCTestExpectation(description: "Voice list should be empty initially")
    let firstExpectation = XCTestExpectation(description: "First voice should be added")
    let secondExpectation = XCTestExpectation(description: "Second voice should be added")
    
    let viewModel = MyViewModel()
    
    let firstVoice = Voice.fixture()
    let secondVoice = Voice.fixture()

    viewModel.testVoiceListSubject
        .sink { newValue in
            switch newValue.count {
            case 0:
                initialExpectation.fulfill()
            case 1:
                if newValue.first == firstVoice {
                    firstExpectation.fulfill()
                    viewModel.addVoice(secondVoice)
                }
            case 2:
                if newValue.last == secondVoice {
                    secondExpectation.fulfill()
                }
            default:
                break
            }
        }
        .store(in: &cancellables)

    viewModel.addVoice(firstVoice)

    wait(for: [initialExpectation, firstExpectation, secondExpectation], timeout: 1)
}

This approach works great because we get all the simplicity and performance benefits from the new @Observable macro, since this CurrentValueSubject doesn't have any side effects like @Published had, and it requires minimal setup. We assume that someday Apple will add a way to listen to properties in classes that use the @Observable macro directly, which could provide a more integrated solution. For now, this method allows us to retain the testability of our ViewModel while adapting to the new @Observable macro, ensuring a smooth transition as we leverage the new features in Swift.

1
Cristik On

You can use the withObservationTracking(_:onChange:) function, and your test would change to something like this:

func testVoicePropertyChange() {
    let viewModel = MyViewModel()
    let expectation = XCTestExpectation(description: "Voice property should change")
    let voice = Voice.fixture()

    @Sendable func observe() {
        withObservationTracking {
            if viewModel.voice == voice {
                expectation.fulfill()
            }
        } onChange: {
            DispatchQueue.main.async(execute: observe)
        }
    }
    observe()

    viewModel.updateVoiceProperty(with: voice)

    wait(for: [expectation], timeout: 1)
}

Any properties accessed in the first closure passed to withObservationTracking will result in the onChange closure being executed, where you can schedule another observation (as the onChange closure is called only once).

This enables monitoring multiple properties, allowing more complex scenarios, for example:

withObservationTracking {
    if viewModel.voice == voice {
        expectation.fulfill()
    }
    if viewModel.volume == 15 {
        volumeExpectation.fulfill()
    }
} onChange: {
    DispatchQueue.main.async(execute: observe)
}