Testing view models that rely on an asynchronous data source

I’m building a SwiftUI app that is using the MVVM pattern. The data source for the view models is provided by a custom Publisher for a Realm database. As I’m trying to be good and do a bit of test-driven development, I wrote a test to ensure that the view model responds appropriately to inputs from the SwiftUI front end (specifically in this instance, only querying the Realm once the UI was displayed). The code functions as expected but the test doesn’t…

The issue I’m experiencing is a failure of the test case. I am anticipating the the view model will map an input (.onAppear) from the SwiftUI front end into an array of ‘Patients’ and assign this array to its patients property. The code works as expected but XCTAssertEqual fails, reporting that the ‘patients’ property is an empty array after calling ‘viewmodel.assign(.onAppear)’. If I put a property observer on ‘patients’ it does update as expected but the test is not “seeing” this update.

This is almost certainly because I’m not accounting for background processing / thread issues. My normal approach would to set up an expectation but this doesn’t help as I need to use the property I’m interested in to create a Publisher but this completes immediately after emitting the initial state and I don’t know how to keep it “alive” until the expectation expires. Can anyone point me in the right direction?

View model:

final class PatientListViewModel: ObservableObject, UnidirectionalDataFlowType {
    typealias InputType = Input
    
    enum Input {
        case onAppear
    }
    
    private var cancellables = Set<AnyCancellable>()
    private let onAppearSubject = PassthroughSubject<Void, Never>()
    
    // MARK: Output
    @Published private(set) var patients: [Patient] = []
    
    // MARK: Private properties
    private let realmSubject = PassthroughSubject<Array<Patient>, Never>()
    private let realmService: RealmServiceType
    
    // MARK: Initialiser
    init(realmService: RealmServiceType) {
        self.realmService = realmService
        bindInputs()
        bindOutputs()
    }
    
    // MARK: ViewModel protocol conformance (functional)
    func apply(_ input: Input) {
        switch input {
        case .onAppear:
            onAppearSubject.send()
        }
    }

    // MARK: Private methods
    private func bindInputs() {
        let _ = onAppearSubject
            .flatMap { [realmService] _ in realmService.all(Patient.self) }
            .share()
            .eraseToAnyPublisher()
            .receive(on: RunLoop.main)
            .subscribe(realmSubject)
            .store(in: &cancellables)
    }
    
    private func bindOutputs() {
        let _ = realmSubject
            .assign(to: \.patients, on: self)
            .store(in: &cancellables)
    }
}

Test class: (very bulky due to my debugging code!)

import XCTest
import RealmSwift
import Combine

@testable import AthenaVS

class AthenaVSTests: XCTestCase {
    private var cancellables = Set<AnyCancellable>()
    private var service: RealmServiceType?
    
    override func setUp() {
        service = TestRealmService()
    }
    
    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        service = nil
        cancellables.removeAll()
    }

        func testPatientListViewModel() {
            let viewModel = PatientListViewModel(realmService: service!)
            let expectation = self.expectation(description: #function)
            var outcome = Array<Patient>()
            
            let _ = viewModel.patients.publisher.collect()
               .handleEvents(receiveSubscription: { (subscription) in
                    print("Receive subscription")
                }, receiveOutput: { output in
                    print("Received output: \(output)")
                    outcome = output
                }, receiveCompletion: { _ in
                    print("Receive completion")
                    expectation.fulfill()
                }, receiveCancel: {
                    print("Receive cancel")
                    expectation.fulfill()
                }, receiveRequest: { demand in
                    print("Receive request: \(demand)")})
                .sink { _ in }
            .store(in: &cancellables)
            
            viewModel.apply(.onAppear)
            
            waitForExpectations(timeout: 2, handler: nil)
            XCTAssertEqual(outcome.count, 4, "ViewModel state should change once triggered")
        }
    }

SwiftUI View

struct ContentView: View {
    @ObservedObject var viewModel: PatientListViewModel
    
    var body: some View {
        NavigationView {
            
            List {
                ForEach(viewModel.patients) { patient in
                    Text(patient.name)
                }
                .onDelete(perform: delete )
            }
            .navigationBarTitle("Patients")
            .navigationBarItems(trailing:
                Button(action: { self.viewModel.apply(.onAdd) })
                { Image(systemName: "plus.circle")
                    .font(.title)
                }
            )
            
        }
        .onAppear(perform: { self.viewModel.apply(.onAppear) })
    }
    
    func delete(at offset: IndexSet) {
        viewModel.apply(.onDelete(offset))
    }
}

Realm Service

protocol RealmServiceType {
    func all<Element>(_ type: Element.Type, within realm: Realm) -> AnyPublisher<Array<Element>, Never> where Element: Object
    
    @discardableResult
    func addPatient(_ name: String, to realm: Realm) throws -> AnyPublisher<Patient, Never>
    
    func deletePatient(_ patient: Patient, from realm: Realm)
}

extension RealmServiceType {
    func all<Element>(_ type: Element.Type) -> AnyPublisher<Array<Element>, Never> where Element: Object {
        all(type, within: try! Realm())
    }
    
    func deletePatient(_ patient: Patient) {
        deletePatient(patient, from: try! Realm())
    }
}

final class TestRealmService: RealmServiceType {
    private let patients = [
        Patient(name: "Tiddles"), Patient(name: "Fang"), Patient(name: "Phoebe"), Patient(name: "Snowy")
    ]
    
    init() {
        let realm = try! Realm()
        guard realm.isEmpty else { return }
        try! realm.write {
            for p in patients {
                realm.add(p)
            }
        }
    }
    
    func all<Element>(_ type: Element.Type, within realm: Realm) -> AnyPublisher<Array<Element>, Never> where Element: Object {
        return Publishers.realm(collection: realm.objects(type).sorted(byKeyPath: "name")).eraseToAnyPublisher()
    }
    
    
    func addPatient(_ name: String, to realm: Realm) throws -> AnyPublisher<Patient, Never> {
        let patient = Patient(name: name)
        try! realm.write {
            realm.add(patient)
        }
        return Just(patient).eraseToAnyPublisher()
    }
    
    func deletePatient(_ patient: Patient, from realm: Realm) {
        try! realm.write {
            realm.delete(patient)
        }
    }
    
}

Custom Publisher (using Realm as a backend)

/ MARK: Custom publisher - produces a stream of Object arrays in response to change notifcations on a given Realm collection
extension Publishers {
    struct Realm<Collection: RealmCollection>: Publisher {
        typealias Output = Array<Collection.Element>
        typealias Failure = Never // TODO: Not true but deal with this later
        
        let collection: Collection
        
        init(collection: Collection) {
            self.collection = collection
        }
        
        func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
            let subscription = RealmSubscription(subscriber: subscriber, collection: collection)
            subscriber.receive(subscription: subscription)
        }
    }
}

// MARK: Convenience accessor function to the custom publisher
extension Publishers {
    static func realm<Collection: RealmCollection>(collection: Collection) -> Publishers.Realm<Collection> {
        return Publishers.Realm(collection: collection)
    }
}

// MARK: Custom subscription
private final class RealmSubscription<S: Subscriber, Collection: RealmCollection>: Subscription where S.Input == Array<Collection.Element> {
    private var subscriber: S?
    private let collection: Collection
    private var notificationToken: NotificationToken?
    
    init(subscriber: S, collection: Collection) {
        self.subscriber = subscriber
        self.collection = collection
        
        self.notificationToken = collection.observe { (changes: RealmCollectionChange) in
            switch changes {
            case .initial:
                // Results are now populated and can be accessed without blocking the UI
                let _ = subscriber.receive(Array(collection.elements))
            //            case .update(_, let deletions, let insertions, let modifications):
            case .update(_, _, _, _):
                let _ = subscriber.receive(Array(collection.elements))
            case .error(let error):
                fatalError("\(error)")
                #warning("Impl error handling - do we want to fail or log and recover?")
            }
        }
    }
    
    func request(_ demand: Subscribers.Demand) {
        // no impl as RealmSubscriber is effectively just a sink
    }
    
    func cancel() {
        subscriber = nil
        notificationToken = nil
    }
}

The issue I’m experiencing is a failure of the test case. I am anticipating the the view model will map an input (.onAppear) from the SwiftUI front end into an array of ‘Patients’ and assign this array to its patients property. The code works as expected but XCTAssertEqual fails, reporting that the ‘patients’ property is an empty array after calling ‘viewmodel.assign(.onAppear)’. If I put a property observer on ‘patients’ it does update as expected but the test is not “seeing” this.

@rustprooffish Do you still have issues with this?