Supporting Reactive Updates

I'd like to support reactive updates to my View Models, that are unit testable, and am running into some challenges. I'm wondering if I'm approaching this problem from the wrong angle or if one must consider a framework that manages state, like Point-Free, to support my goal.

I have an @Published property in my view model that publishes messages that will display in my UI. There is a MessageService that stores and retrieves messages.

@Published private(set) messages = [Message]()
private let messageService: MessageService

The messages may be updated "reactively" via the service. I'm using a publisher in the MessageService for all of my updates. This is to have a single source of truth for the messages in the service, rather than duplicating that state across my app.

cancellable = messageService.messagesPublisher
    .receive(on: DispatchQueue.main)
    .assign(to: \.messages, on: self)

The messages also may be updated in the view Model from several view actions. Example:

    func addMessageTapped() async {
        let randomMessage = Message(id: uuidService.createUUID(), text: "Hello World!")
        await messageService.addMessage(randomMessage) //Triggers messages to update via Combine
    }

This is where my choice to update my ViewModel "reactively" is causing some challenges. A few problems:

  1. In addMessageTapped, after I ask MessageService to make an update, the published messages property in the view model still does not update immediately. One must wait for combine to publish the value on the main thread. This seems like an inconsistent state for these methods in the view model.

  2. Unit testing the view model becomes difficult. Related to number 1, I can't make a change and easily assert the published value is updated. I need to do some "tricks" to wait for the combine update to propogate.

I experimented with various options here such as:

  • In methods that update the service, await for the next value to be published. I could alternatively do this just in the unit tests. This felt like a hack.

  • Update my messages property "eagerly" in my view model after I make an update. In other words, I could update the messages array right after I update the service, in anticipation of how it will update via the service publisher. This approach seems redundant and it could be be a source of bugs if the service updated differently than my ViewModel.

  • Give up on reactive updates from the MessageService and instead make the view model the source of truth in my app. I see this in many projects. My hesitation here is if you have multiple views, you need them to coordinate their updates together which may reintroduce this same challenge -- between various View Models rather than the Service.

In terms of unit testing, try thinking of your units more granularly. The way I would test the above situation is by separating it out into two behaviors:

  1. Every time the service emits a new value, the view model updates its published property in response.
  2. Every time the service receives a call to its add method, it calls the corresponding method on the service with the proper value.

This doesn’t solve the challenges arising from asynchrony here, but it does allow you to clearly separate service responsibilities from view model responsibilities. In order to do this you’ll definitely need a way to instantiate and pass a fake service, which is also a good thing to have handy.

We’ve discussed architectural concerns such as this a bunch of times on our team and I think the solution you are converging on is the least bad one possible using “vanilla” Swift constructs such as Published and whatnot. In particular I think concentrating all the inconveniences related to asynchrony in the view model is the least bad approach. With test expectations and maybe a couple helper functions here and there it can be manageable, in my experience. :sweat_smile:

1 Like

Is messageService publishing on the main thread? If not - there is no way to avoid waiting, no matter what you do.

If it does, you can make messages computed property which returns underlying source of truth. And forward objectWillChange of the messageService to the objectWillChange of the VM.

1 Like

Without giving any opinion on the type of test you are writing, there is an additional option that you may not have considered yet.

Rather than using DispatchQueue.main directly in your code, which as you noted complicates testing since it opens you up to the whims of scheduling, you can instead inject an explicit Combine scheduler into the object and use it. This allows you to use DispatchQueue.main in production, but you can substitute a kind of "immediate scheduler" in tests that completely remove the thread hop you are experience, and allow you to write a test very easily.

However, the tools to do this do not come with Combine by default. You have to build them yourself (both an explicit AnyScheduler type erased wrapper to aid in injection and an ImmediateScheduler conformance to use in tests), or you can use this library to get access to those tools right away.

2 Likes

This distinction between View Model and injected Service responsibilities you made is helpful. It seems like domain logic, that is not particular to a view, could be delegated to a View Model's injected Services, with their own test suite. I've seen quite a few View Models that are doing data processing/manipulation that overcomplicates it.

Thanks and I will check it out! This research actually started after watching your awesome Modern Swift UI series. I'm trying to discover how testable I can make SwiftUI without any external libraries (really Vanilla SwiftUI), as the open source project I'm contributing to is very restricted. I'm hitting some common issues already solved by you all - but it is great to reference for a better understanding.