diff --git a/Sources/IMVVM/Interactor.swift b/Sources/IMVVM/Interactor.swift index 882ffe6..75e8492 100644 --- a/Sources/IMVVM/Interactor.swift +++ b/Sources/IMVVM/Interactor.swift @@ -23,30 +23,6 @@ import Combine import Foundation -/// The observer of an `Interactor`'s lifecycle events. -/// -/// This allows the implementation of this protocol to bind to the interactor's lifecycle events. -public protocol InteractorLifecycleObserver { - /// Override this function to setup the subscriptions the receiver requires on start. - /// - /// All the created subscriptions returned from this function are bound to the deinit lifecycle of the interactor. - /// - /// class MyInteractor: Interactor { - /// @CancellableBuilder - /// override func onLoad() -> [AnyCancellable] { - /// myDataStream - /// .sink {...} - /// - /// mySecondDataStream - /// .sink {...} - /// } - /// } - /// - /// - Returns: An array of subscription `AnyCancellable`. - @CancellableBuilder - func onLoad() -> [AnyCancellable] -} - /// The base class of an object providing data to a view's interactor and functionality to handle user events such as /// button taps. /// @@ -60,16 +36,37 @@ public protocol InteractorLifecycleObserver { /// subscription cancellables. Subclasses should override the lifecycle functions such as `onLoad` and `onViewAppear` /// to setup all the necessary subscriptions. /// -/// An `interactor` is a `ViewLifecycleObserver`. It should be bound to the associated view via the view's -/// `interactable()` modifier. This allows the interactor to activate its lifecycle functions. +/// - Important: An `interactor` is a `ViewLifecycleObserver`. It should be bound to the associated +/// view via the view's `bind(observer:)` modifier, inside the view implementation. This allows the interactor to +/// activate its lifecycle functions. /// -/// - Note: An `interactor` is not an `ObservableObject` since it should NOT directly provide view data to the -/// associated view. Instead, it should provide network data to a `ViewModel` that transforms it into presentation -/// data for the view to display. +/// struct MyView: View { +/// @StateObject var interactor: MyInteractor /// -/// - Important: An interactor must be bound to its corresponding view via the view's `bind(observer:)` function -/// in order for the interactor receive lifecycle events. This should be performed before the view is attached to the -/// view hierarchy. +/// body: some View { +/// myContent +/// .bind(observer: interactor) +/// } +/// } +/// +/// The view must declare and reference the interactor as a `@StateObject`. This allows different +/// instances of the view to reference the same interactor instance, therefore maintaining the states of the view's +/// data. This occurs when multiple instances of the same view are instantiated over time by SwiftUI, as the view +/// updates and changes due to data changes or user interactions. And because the interactor is a `@StateObject`, +/// the binding of the interactor via the `bind(observer:)` modifier must be inside the view's `body`. +/// +/// In order to avoid SwiftUI retaining duplicate instances of the interactor, when the interactor is passed into the +/// view's constructor, it might be passed in as an `@autoclosure`. In other words, invoking the interactor's +/// constructor must be nested within the view's constructor: +/// +/// func makeMyView() -> some View { +/// MyView(interactor: MyInteractor(...)) +/// } +/// +/// - Note: An `interactor` should NOT directly provide view data to the associated view. It should only contain +/// business logic. If the interactor needs to update the view with new data, it should provide the data to a +/// `ViewModel` that transforms it into presentation data for the view to display. Please see `ViewModelInteractor` +/// for details on this use case. open class Interactor { /// Stores cancellables until deinit. let deinitCancelBag = SynchronizedCancelBag() @@ -103,8 +100,7 @@ open class Interactor { /// Override this function to perform logic or setup the subscriptions when the view has appeared. /// /// All the created subscriptions returned from this function are bound to the disappearance of this interactor's - /// corresponding view. This requires this interactor to be bound to the lifecycle of a view via the view's - /// `interactable()` modifier. See `InteractableView` for more details. + /// corresponding view. /// /// class MyInteractor: Interactor { /// @CancellableBuilder @@ -133,29 +129,52 @@ open class Interactor { // MARK: - ViewLifecycleObserver Conformance extension Interactor: ViewLifecycleObserver { - public final func viewDidAppear() { + enum ViewEventOccurrence { + case invalid + case valid + case firstTime + } + + @discardableResult + func processViewDidAppear() -> ViewEventOccurrence { guard !hasViewAppeared else { - return + return .invalid } hasViewAppeared = true + var occurrence = ViewEventOccurrence.valid if !isLoaded { isLoaded = true + occurrence = .firstTime + deinitCancelBag.store(onLoad()) } viewAppearanceCancelBag.store(onViewAppear()) + + return occurrence } - public final func viewDidDisappear() { + @discardableResult + func processViewDidDisappear() -> ViewEventOccurrence { guard hasViewAppeared else { - return + return .invalid } hasViewAppeared = false viewAppearanceCancelBag.cancelAll() onViewDisappear() + + return .valid + } + + public final func viewDidAppear() { + processViewDidAppear() + } + + public final func viewDidDisappear() { + processViewDidDisappear() } } diff --git a/Sources/IMVVM/View+Lifecycle+Model.swift b/Sources/IMVVM/View+Lifecycle+Model.swift new file mode 100644 index 0000000..3755f9c --- /dev/null +++ b/Sources/IMVVM/View+Lifecycle+Model.swift @@ -0,0 +1,68 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import SwiftUI + +/// The observer of a SwiftUI view's lifecycle events. +/// +/// This protocol should be used in conjunction with the `bind(observer:)` function of a `View`. This allows the +/// implementation of this protocol to receive the view's various lifecycle events to perform business logic accordingly. +/// +/// This protocol conforms to `ObservableObject` to support retaining this instance as a `@StateObject` in the +/// view that performs the `bind(observer:)` function. +public protocol ViewWithModelLifecycleObserver: ObservableObject { + /// The model of the view that this observer may mutate to provide data to the view. + associatedtype ViewModelType: ViewModel + + /// Notify the observer when the bound `View` has appeared. + /// + /// - Parameters: + /// - viewModel: The model of the view that this observer may mutate to provide data to the view. + func viewDidAppear(viewModel: ViewModelType) + + /// Notify the observer when the bound `View` has disappeared. + /// + /// - Parameters: + /// - viewModel: The model of the view that this observer may mutate to provide data to the view. + func viewDidDisappear(viewModel: ViewModelType) +} + +extension View { + /// Bind the given lifecycle observer to this view. + /// + /// - Parameters: + /// - observer: The observer to be bound and receive this view's lifecycle events. + /// - viewModel: The model of this view that this observer may mutate to provide data to the view. + /// - Returns: This view with the observer bound. + public func bind( + observer: Observer, + viewModel: Observer.ViewModelType + ) -> some View { + onAppear { + observer.viewDidAppear(viewModel: viewModel) + } + .onDisappear { + observer.viewDidDisappear(viewModel: viewModel) + } + } +} diff --git a/Sources/IMVVM/View+Lifecycle.swift b/Sources/IMVVM/View+Lifecycle.swift index 7553df2..4d4cec3 100644 --- a/Sources/IMVVM/View+Lifecycle.swift +++ b/Sources/IMVVM/View+Lifecycle.swift @@ -27,7 +27,10 @@ import SwiftUI /// /// This protocol should be used in conjunction with the `bind(observer:)` function of a `View`. This allows the /// implementation of this protocol to receive the view's various lifecycle events to perform business logic accordingly. -public protocol ViewLifecycleObserver { +/// +/// This protocol conforms to `ObservableObject` to support retaining this instance as a `@StateObject` in the +/// view that performs the `bind(observer:)` function. +public protocol ViewLifecycleObserver: ObservableObject { /// Notify the observer when the bound `View` has appeared. func viewDidAppear() @@ -55,6 +58,7 @@ extension View { /// - Parameters: /// - observer: The observer to be bound and receive this view's lifecycle events. /// - Returns: The type erased version of thisview with the observer bound. + @available(*, deprecated, message: "Binding a view with an observer should happen inside a view.") public func bindTypeErased(observer: Observer) -> TypeErasedView { onAppear { observer.viewDidAppear() diff --git a/Sources/IMVVM/ViewModelInteractor.swift b/Sources/IMVVM/ViewModelInteractor.swift new file mode 100644 index 0000000..cf12b20 --- /dev/null +++ b/Sources/IMVVM/ViewModelInteractor.swift @@ -0,0 +1,108 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import Foundation + +/// An `Interactor` that has an associated view model used to transform the data from this interactor to presentation +/// data for the corresponding view to display. +/// +/// - Important: Please see `Interactor` documentation for binding requirements. +open class ViewModelInteractor: Interactor { + /// Override this function to setup the subscriptions this interactor requires on start. + /// + /// All the created subscriptions returned from this function are bound to the deinit lifecycle of this interactor. + /// + /// class MyInteractor: Interactor { + /// @CancellableBuilder + /// override func onLoad(viewModel: MyViewModel) -> [AnyCancellable] { + /// myDataStream + /// .sink {...} + /// + /// mySecondDataStream + /// .sink {...} + /// } + /// } + /// + /// - Parameters: + /// - viewModel: The view model used to transform and provide presentation data for the corresponding view to + /// display. + /// - Returns: An array of subscription `AnyCancellable`. + @CancellableBuilder + open func onLoad(viewModel: ViewModelType) -> [AnyCancellable] {} + + /// Override this function to perform logic or setup the subscriptions when the view has appeared. + /// + /// All the created subscriptions returned from this function are bound to the disappearance of this interactor's + /// corresponding view. + /// + /// class MyInteractor: Interactor { + /// @CancellableBuilder + /// override func onViewAppear(viewModel: MyViewModel) -> [AnyCancellable] { + /// myDataStream + /// .sink {...} + /// + /// mySecondDataStream + /// .sink {...} + /// } + /// } + /// + /// - Parameters: + /// - viewModel: The view model used to transform and provide presentation data for the corresponding view to + /// display. + /// - Returns: An array of subscription `AnyCancellable`. + @CancellableBuilder + open func onViewAppear(viewModel: ViewModelType) -> [AnyCancellable] {} + + /// Override this function to perform logic when the interactor's view disappears. + /// + /// - Parameters: + /// - viewModel: The view model used to transform and provide presentation data for the corresponding view to + /// display. + open func onViewDisappear(viewModel: ViewModelType) {} +} + +// MARK: - ViewWithModelLifecycleObserver Conformance + +extension ViewModelInteractor: ViewWithModelLifecycleObserver { + public final func viewDidAppear(viewModel: ViewModelType) { + let occurrence = processViewDidAppear() + guard occurrence != .invalid else { + return + } + + if occurrence == .firstTime { + deinitCancelBag.store(onLoad(viewModel: viewModel)) + } + + viewAppearanceCancelBag.store(onViewAppear(viewModel: viewModel)) + } + + public final func viewDidDisappear(viewModel: ViewModelType) { + let occurrence = processViewDidDisappear() + guard occurrence != .invalid else { + return + } + + onViewDisappear(viewModel: viewModel) + } +} diff --git a/Tests/IMVVMTests/Mocks.swift b/Tests/IMVVMTests/Mocks.swift index 150c1dd..c7bd901 100644 --- a/Tests/IMVVMTests/Mocks.swift +++ b/Tests/IMVVMTests/Mocks.swift @@ -1,38 +1,29 @@ -// MIT License // -// Copyright (c) 2022 Yi Wang +// Copyright (c) 2022. City Storage Systems LLC // -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. import Combine import Foundation @testable import IMVVM -class MockInteractor: Interactor { +class MockViewModel: ViewModel {} + +class MockViewModelInteractor: ViewModelInteractor { private let _onLoad: () -> [AnyCancellable] + private let _onLoadViewModel: (MockViewModel) -> [AnyCancellable] private let _onViewAppear: () -> [AnyCancellable] + private let _onViewAppearViewModel: (MockViewModel) -> [AnyCancellable] - init(onLoad: @escaping () -> [AnyCancellable] = { [] }, - onViewAppear: @escaping () -> [AnyCancellable] = { [] }) - { + init( + onLoad: @escaping () -> [AnyCancellable] = { [] }, + onLoadViewModel: @escaping (MockViewModel) -> [AnyCancellable] = { _ in [] }, + onViewAppear: @escaping () -> [AnyCancellable] = { [] }, + onViewAppearViewModel: @escaping (MockViewModel) -> [AnyCancellable] = { _ in [] } + ) { self._onLoad = onLoad + self._onLoadViewModel = onLoadViewModel self._onViewAppear = onViewAppear + self._onViewAppearViewModel = onViewAppearViewModel } var onLoadCallCount = 0 @@ -41,14 +32,31 @@ class MockInteractor: Interactor { return _onLoad() } + var onLoadViewModelCallCount = 0 + override func onLoad(viewModel: MockViewModel) -> [AnyCancellable] { + onLoadViewModelCallCount += 1 + return _onLoadViewModel(viewModel) + } + var onViewAppearCallCount = 0 override func onViewAppear() -> [AnyCancellable] { onViewAppearCallCount += 1 return _onViewAppear() } + var onViewAppearViewModelCallCount = 0 + override func onViewAppear(viewModel: MockViewModel) -> [AnyCancellable] { + onViewAppearViewModelCallCount += 1 + return _onViewAppearViewModel(viewModel) + } + var onViewDisappearCallCount = 0 override func onViewDisappear() { onViewDisappearCallCount += 1 } + + var onViewDisappearViewModelCallCount = 0 + override func onViewDisappear(viewModel: MockViewModel) { + onViewDisappearViewModelCallCount += 1 + } } diff --git a/Tests/IMVVMTests/InteractorTests.swift b/Tests/IMVVMTests/ViewModelInteractorTests.swift similarity index 70% rename from Tests/IMVVMTests/InteractorTests.swift rename to Tests/IMVVMTests/ViewModelInteractorTests.swift index e33746e..ea8605d 100644 --- a/Tests/IMVVMTests/InteractorTests.swift +++ b/Tests/IMVVMTests/ViewModelInteractorTests.swift @@ -25,31 +25,48 @@ import Foundation @testable import IMVVM import XCTest -class InteractorTests: XCTestCase { +class ViewModelInteractorTests: XCTestCase { func test_viewLifecycleEvents() { - weak var interactorWeakRef: MockInteractor? + weak var interactorWeakRef: MockViewModelInteractor? var onLoadCancellableCallCount = 0 + var onLoadViewModelCancellableCallCount = 0 var onViewAppearCancellableCallCount = 0 autoreleasepool { let onLoadCancellable = AnyCancellable { onLoadCancellableCallCount += 1 } + let onLoadViewModelCancellable = AnyCancellable { + onLoadViewModelCancellableCallCount += 1 + } - let interactor = MockInteractor(onLoad: { [onLoadCancellable] }) + let interactor = MockViewModelInteractor( + onLoad: { + [onLoadCancellable] + }, + onLoadViewModel: { viewModel in + XCTAssertNotNil(viewModel) + return [onLoadViewModelCancellable] + } + ) interactorWeakRef = interactor + let viewModel = MockViewModel() XCTAssertNotNil(interactorWeakRef) XCTAssertEqual(interactor.onLoadCallCount, 0) + XCTAssertEqual(interactor.onLoadViewModelCallCount, 0) XCTAssertEqual(interactor.onViewAppearCallCount, 0) + XCTAssertEqual(interactor.onViewAppearViewModelCallCount, 0) XCTAssertEqual(interactor.onViewDisappearCallCount, 0) + XCTAssertEqual(interactor.onViewDisappearViewModelCallCount, 0) XCTAssertEqual(onLoadCancellableCallCount, 0) + XCTAssertEqual(onLoadViewModelCancellableCallCount, 0) XCTAssertEqual(onViewAppearCancellableCallCount, 0) XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) XCTAssertTrue(interactor.viewAppearanceCancelBag.isEmpty()) - interactor.viewDidAppear() + interactor.viewDidAppear(viewModel: viewModel) var didReceiveValue = false let subject = PassthroughSubject() @@ -66,36 +83,46 @@ class InteractorTests: XCTestCase { .cancelOnViewDidDisappear(of: interactor) XCTAssertEqual(interactor.onLoadCallCount, 1) + XCTAssertEqual(interactor.onLoadViewModelCallCount, 1) XCTAssertEqual(interactor.onViewAppearCallCount, 1) + XCTAssertEqual(interactor.onViewAppearViewModelCallCount, 1) XCTAssertEqual(interactor.onViewDisappearCallCount, 0) + XCTAssertEqual(interactor.onViewDisappearViewModelCallCount, 0) XCTAssertEqual(onLoadCancellableCallCount, 0) + XCTAssertEqual(onLoadViewModelCancellableCallCount, 0) XCTAssertEqual(onViewAppearCancellableCallCount, 0) XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadCancellable)) + XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadViewModelCancellable)) XCTAssertFalse(interactor.viewAppearanceCancelBag.isEmpty()) subject.send(1) XCTAssertTrue(didReceiveValue) - interactor.viewDidDisappear() + interactor.viewDidDisappear(viewModel: viewModel) XCTAssertEqual(interactor.onLoadCallCount, 1) + XCTAssertEqual(interactor.onLoadViewModelCallCount, 1) XCTAssertEqual(interactor.onViewAppearCallCount, 1) + XCTAssertEqual(interactor.onViewAppearViewModelCallCount, 1) XCTAssertEqual(interactor.onViewDisappearCallCount, 1) + XCTAssertEqual(interactor.onViewDisappearViewModelCallCount, 1) XCTAssertEqual(onLoadCancellableCallCount, 0) + XCTAssertEqual(onLoadViewModelCancellableCallCount, 0) XCTAssertEqual(onViewAppearCancellableCallCount, 1) XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadCancellable)) XCTAssertTrue(interactor.viewAppearanceCancelBag.isEmpty()) } XCTAssertEqual(onLoadCancellableCallCount, 1) + XCTAssertEqual(onLoadViewModelCancellableCallCount, 1) XCTAssertEqual(onViewAppearCancellableCallCount, 1) XCTAssertNil(interactorWeakRef) } - func test_sink_cancelOnDeinit_removeBoundCancellable() { - let interactor = MockInteractor() - interactor.viewDidAppear() + func test_sinkWithAutoCancelOnDeinit_removeBoundCancellable() { + let interactor = MockViewModelInteractor() + interactor.viewDidAppear(viewModel: MockViewModel()) XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) @@ -110,14 +137,16 @@ class InteractorTests: XCTestCase { subject.send(1) XCTAssertFalse(interactor.deinitCancelBag.isEmpty()) + + subject.send(completion: .finished) } - func test_sink_cancelOnDeinit_subscriptionRetainInteractor_NoRetainInteractor() { - weak var interactorWeakRef: MockInteractor? + func test_sinkWithAutoCancelOnDeinit_subscriptionRetainInteractor_NoRetainInteractor() { + weak var interactorWeakRef: MockViewModelInteractor? autoreleasepool { - let interactor = MockInteractor() - interactor.viewDidAppear() + let interactor = MockViewModelInteractor() + interactor.viewDidAppear(viewModel: MockViewModel()) interactorWeakRef = interactor XCTAssertNotNil(interactorWeakRef) @@ -144,25 +173,25 @@ class InteractorTests: XCTestCase { } func test_activate_shouldCallDidBecomeActive() { - let interactor = MockInteractor() + let interactor = MockViewModelInteractor() XCTAssertEqual(interactor.onLoadCallCount, 0) XCTAssertFalse(interactor.isLoaded) - interactor.viewDidAppear() + interactor.viewDidAppear(viewModel: MockViewModel()) XCTAssertEqual(interactor.onLoadCallCount, 1) XCTAssertTrue(interactor.isLoaded) - interactor.viewDidDisappear() + interactor.viewDidDisappear(viewModel: MockViewModel()) XCTAssertEqual(interactor.onLoadCallCount, 1) XCTAssertTrue(interactor.isLoaded) } - func test_sink_cancelOnDisappear_removeBoundCancellable() { - let interactor = MockInteractor() - interactor.viewDidAppear() + func test_sinkWithAutoCancelOnDisappear_removeBoundCancellable() { + let interactor = MockViewModelInteractor() + interactor.viewDidAppear(viewModel: MockViewModel()) XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) @@ -183,12 +212,12 @@ class InteractorTests: XCTestCase { XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) } - func test_sink_cancelOnDisappear_subscriptionRetainInteractor_NoRetainInteractor() { - weak var interactorWeakRef: MockInteractor? + func test_sinkWithAutoCancelOnDisappear_subscriptionRetainInteractor_NoRetainInteractor() { + weak var interactorWeakRef: MockViewModelInteractor? autoreleasepool { - let interactor = MockInteractor() - interactor.viewDidAppear() + let interactor = MockViewModelInteractor() + interactor.viewDidAppear(viewModel: MockViewModel()) interactorWeakRef = interactor XCTAssertNotNil(interactorWeakRef)