Тестирование моделей представления, основанных на асинхронном источнике данных - PullRequest
0 голосов
/ 16 января 2020

Я создаю приложение SwiftUI, которое использует шаблон MVVM. Источник данных для моделей представлений предоставляется пользовательским издателем для базы данных Realm. Поскольку я стараюсь быть хорошим и делаю небольшую разработку, основанную на тестировании, я написал тест, чтобы убедиться, что модель представления должным образом реагирует на входные данные из интерфейса SwiftUI (особенно в этом случае, запрашивая Realm только после пользовательского интерфейса). был отображен). Код работает, как и ожидалось, но тест не ...

Это почти наверняка, потому что я не учитываю проблемы фоновой обработки / потоков. Мой обычный подход состоит в том, чтобы установить ожидание, но это не помогает, так как мне нужно использовать интересующее меня свойство для создания Publisher, но это завершается сразу после создания исходного состояния, и я не знаю, как его сохранить. "живой", пока не истечет ожидание. Кто-нибудь может указать мне правильное направление?

Просмотр модели:

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)
    }
}

Тестовый класс: (очень громоздкий из-за моего кода отладки!)

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 (с использованием Realm в качестве бэкэнда)

/ 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
    }
}

Проблема, с которой я столкнулся, - это сбой тестового примера. Я ожидаю, что модель представления отобразит вход (.onAppear) из внешнего интерфейса SwiftUI в массив 'Patients' и назначит этот массив его свойству patients. Код работает, как и ожидалось, но XCTAssertEqual завершается ошибкой, сообщая, что свойство 'Patient' является пустым массивом после вызова 'viewmodel.assign (.onAppear)'. Если я назначаю наблюдателя свойства «пациентам», он обновляется, как и ожидалось, но тест не «видит» это.

...