From 4a3255adcd7d9d6e4279ace7c4b0de64afa06b04 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sun, 22 Jun 2025 23:54:56 -0500 Subject: [PATCH 1/4] Add basic Interactor tests --- RIBsTests/Interactor/InteractorTests.swift | 109 +++++++++++++++++++++ RIBsTests/Mocks.swift | 41 ++++---- 2 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 RIBsTests/Interactor/InteractorTests.swift diff --git a/RIBsTests/Interactor/InteractorTests.swift b/RIBsTests/Interactor/InteractorTests.swift new file mode 100644 index 0000000..fa73596 --- /dev/null +++ b/RIBsTests/Interactor/InteractorTests.swift @@ -0,0 +1,109 @@ +// +// InteractorTests.swift +// RIBs +// +// Created by Alex Bush on 6/22/25. +// + +@testable import RIBs +import XCTest + +final class InteractorTests: XCTestCase { + + private var interactor: InteractorMock! + + override func setUp() { + super.setUp() + + interactor = InteractorMock() // NOTE: we're using InteractorMock here to test the underlying parent class, Interactor, behavior so this is appropriate here. + } + + func test_interactorIsInactiveByDefault() { + XCTAssertFalse(interactor.isActive) + interactor.isActiveStream.subscribe { isActive in + XCTAssertFalse(isActive) + } + } + + func test_isActive_whenStarted_isTrue() { + // give + // when + interactor.activate() + // then + XCTAssertTrue(interactor.isActive) + interactor.isActiveStream.subscribe { isActive in + XCTAssertTrue(isActive) + } + } + + func test_isActive_whenDeactivated_isFalse() { + // given + interactor.activate() + // when + interactor.deactivate() + // then + XCTAssertFalse(interactor.isActive) + interactor.isActiveStream.subscribe { isActive in + XCTAssertFalse(isActive) + } + } + + func test_didBecomeActive_isCalledWhenStarted() { + // given + // when + interactor.activate() + // then + XCTAssertEqual(interactor.didBecomeActiveCallCount, 1) + } + + func test_didBecomeActive_isNotCalledWhenAlreadyActive() { + // given + interactor.activate() + XCTAssertEqual(interactor.didBecomeActiveCallCount, 1) + // when + interactor.activate() + // then + XCTAssertEqual(interactor.didBecomeActiveCallCount, 1) + } + + func test_willResignActive_isCalledWhenDeactivated() { + // given + interactor.activate() + // when + interactor.deactivate() + // then + XCTAssertEqual(interactor.willResignActiveCallCount, 1) + } + + func test_willResignActive_isNotCalledWhenAlreadyInactive() { + // given + interactor.activate() + interactor.deactivate() + XCTAssertEqual(interactor.willResignActiveCallCount, 1) + // when + interactor.deactivate() + // then + XCTAssertEqual(interactor.willResignActiveCallCount, 1) + } + + // MARK: - BEGIN Observables Attached to Interactor + func test_isActiveStream_completedOnInteractorDeinit() { + // given + var isActiveStreamCompleted = false + interactor.isActiveStream.subscribe { _ in + + } onError: { _ in + + } onCompleted: { + isActiveStreamCompleted = true + } onDisposed: { + + } + // when + interactor = nil + // then + XCTAssertTrue(isActiveStreamCompleted) + + } + // MARK: Observables Attached to Interactor END - +} diff --git a/RIBsTests/Mocks.swift b/RIBsTests/Mocks.swift index ad8a8a0..08fe1bd 100644 --- a/RIBsTests/Mocks.swift +++ b/RIBsTests/Mocks.swift @@ -44,27 +44,28 @@ class ViewControllableMock: ViewControllable { let uiviewController = UIViewController(nibName: nil, bundle: nil) } -class InteractorMock: Interactable { - var isActive: Bool { - return active.value - } - - var isActiveStream: Observable { - return active.asObservable() - } - - private let active = BehaviorRelay(value: false) - - init() {} - - // MARK: - Lifecycle - - func activate() { - active.accept(true) +class InteractorMock: Interactor { + var didBecomeActiveHandler: (() -> ())? + var didBecomeActiveCallCount: Int = 0 + var willResignActiveHandler: (() -> ())? + var willResignActiveCallCount: Int = 0 + + override func didBecomeActive() { + didBecomeActiveCallCount += 1 + super.didBecomeActive() + + if let didBecomeActiveHandler = didBecomeActiveHandler { + didBecomeActiveHandler() + } } - - func deactivate() { - active.accept(false) + + override func willResignActive() { + willResignActiveCallCount += 1 + super.willResignActive() + + if let willResignActiveHandler = willResignActiveHandler { + willResignActiveHandler() + } } } From f5b913ffb61a1005d1e6ad67e89423e6bb18ab87 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Mon, 23 Jun 2025 11:57:58 -0500 Subject: [PATCH 2/4] Add interactor tests --- RIBsTests/Interactor/InteractorTests.swift | 87 +++++++++++++++++++--- 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/RIBsTests/Interactor/InteractorTests.swift b/RIBsTests/Interactor/InteractorTests.swift index fa73596..5797869 100644 --- a/RIBsTests/Interactor/InteractorTests.swift +++ b/RIBsTests/Interactor/InteractorTests.swift @@ -7,6 +7,7 @@ @testable import RIBs import XCTest +import RxSwift final class InteractorTests: XCTestCase { @@ -20,7 +21,7 @@ final class InteractorTests: XCTestCase { func test_interactorIsInactiveByDefault() { XCTAssertFalse(interactor.isActive) - interactor.isActiveStream.subscribe { isActive in + let _ = interactor.isActiveStream.subscribe { isActive in XCTAssertFalse(isActive) } } @@ -31,7 +32,7 @@ final class InteractorTests: XCTestCase { interactor.activate() // then XCTAssertTrue(interactor.isActive) - interactor.isActiveStream.subscribe { isActive in + let _ = interactor.isActiveStream.subscribe { isActive in XCTAssertTrue(isActive) } } @@ -43,7 +44,7 @@ final class InteractorTests: XCTestCase { interactor.deactivate() // then XCTAssertFalse(interactor.isActive) - interactor.isActiveStream.subscribe { isActive in + let _ = interactor.isActiveStream.subscribe { isActive in XCTAssertFalse(isActive) } } @@ -86,24 +87,86 @@ final class InteractorTests: XCTestCase { XCTAssertEqual(interactor.willResignActiveCallCount, 1) } - // MARK: - BEGIN Observables Attached to Interactor func test_isActiveStream_completedOnInteractorDeinit() { // given var isActiveStreamCompleted = false - interactor.isActiveStream.subscribe { _ in - - } onError: { _ in - - } onCompleted: { + interactor.activate() + let _ = interactor.isActiveStream.subscribe { _ in } onCompleted: { isActiveStreamCompleted = true - } onDisposed: { - } + // when interactor = nil // then XCTAssertTrue(isActiveStreamCompleted) } - // MARK: Observables Attached to Interactor END - + + // MARK: - BEGIN Observables Attached/Detached to/from Interactor + func test_observableAttachedToInactiveInteactorIsDisposedImmediately() { + // given + var onDisposeCalled = false + let subjectEmiitingValues: PublishSubject = .init() + let observable = subjectEmiitingValues.asObservable().do { _ in } onDispose: { + onDisposeCalled = true + } + // when + observable.subscribe().disposeOnDeactivate(interactor: interactor) + // then + XCTAssertTrue(onDisposeCalled) + } + + func test_observableIsDisposedOnInteractorDeactivation() { + // given + var onDisposeCalled = false + let subjectEmiitingValues: PublishSubject = .init() + let observable = subjectEmiitingValues.asObservable().do { _ in } onDispose: { + onDisposeCalled = true + } + interactor.activate() + observable.subscribe().disposeOnDeactivate(interactor: interactor) + // when + interactor.deactivate() + // then + XCTAssertTrue(onDisposeCalled) + } + + func test_observableIsDisposedOnInteractorDeinit() { + // given + var onDisposeCalled = false + let subjectEmiitingValues: PublishSubject = .init() + let observable = subjectEmiitingValues.asObservable().do { _ in } onDispose: { + onDisposeCalled = true + } + interactor.activate() + observable.subscribe().disposeOnDeactivate(interactor: interactor) + XCTAssertFalse(onDisposeCalled) + // when + interactor = nil + // then + XCTAssertTrue(onDisposeCalled) + } + // MARK: Observables Attached/Detached to/from Interactor END - + + // MARK: - BEGIN Observables Confined to Interactor + func test_observableConfinedToInteractorOnlyEmitsValueWhenInteractorIsActive() { + // given + var emittedValue: Int? + let subjectEmiitingValues: PublishSubject = .init() + let confinedObservable = subjectEmiitingValues.asObservable().confineTo(interactor) + let _ = confinedObservable.confineTo(interactor) + let _ = confinedObservable.subscribe { newValue in + emittedValue = newValue + } + + subjectEmiitingValues.onNext(1) + XCTAssertNil(emittedValue) + // when + interactor.activate() + subjectEmiitingValues.onNext(2) + // then + XCTAssertNotNil(emittedValue) + XCTAssertEqual(emittedValue, 2) + } + // MARK: Observables Confined to Interactor - } From 4053bb2a8af95b9c22e2698c782c9c37f9e4db31 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 26 Jul 2025 11:42:04 -0500 Subject: [PATCH 3/4] Add leak detector tests for presentable interactor --- .../PresentableInteractorTests.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 RIBsTests/Interactor/PresentableInteractorTests.swift diff --git a/RIBsTests/Interactor/PresentableInteractorTests.swift b/RIBsTests/Interactor/PresentableInteractorTests.swift new file mode 100644 index 0000000..7505703 --- /dev/null +++ b/RIBsTests/Interactor/PresentableInteractorTests.swift @@ -0,0 +1,40 @@ +// +// PresentableInteractorTests.swift +// RIBs +// +// Created by Alex Bush on 6/23/25. +// + +@testable import RIBs +import XCTest +import RxSwift + +protocol TestPresenter {} + +final class PresenterMock: TestPresenter {} + +final class PresentableInteractorTests: XCTestCase { + + private var interactor: PresentableInteractor! + + override func setUp() { + super.setUp() + + } + + func test_deinit_doesNotLeakPresenter() { + // given + let presenterMock = PresenterMock() + let disposeBag = DisposeBag() + interactor = PresentableInteractor(presenter: presenterMock) + var status: LeakDetectionStatus = .DidComplete + LeakDetector.instance.status.subscribe { newStatus in + status = newStatus + }.disposed(by: disposeBag) + + // when + interactor = nil + // then + XCTAssertEqual(status, .InProgress) + } +} From 2bf6df8240d7caffcf3ea3498d95c94ef0b975fc Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 26 Jul 2025 16:20:51 -0500 Subject: [PATCH 4/4] Add router and viewable router tests - refactor leak detector singleton to be injectable for testing --- RIBs/Classes/LeakDetector/LeakDetector.swift | 9 +- RIBsTests/LeakDetector/LeakDetectorMock.swift | 39 ++++ RIBsTests/Router/RouterTests.swift | 180 ++++++++++++++++++ RIBsTests/Router/ViewableRouterTests.swift | 56 ++++++ RIBsTests/RouterTests.swift | 67 ------- 5 files changed, 281 insertions(+), 70 deletions(-) create mode 100644 RIBsTests/LeakDetector/LeakDetectorMock.swift create mode 100644 RIBsTests/Router/RouterTests.swift create mode 100644 RIBsTests/Router/ViewableRouterTests.swift delete mode 100644 RIBsTests/RouterTests.swift diff --git a/RIBs/Classes/LeakDetector/LeakDetector.swift b/RIBs/Classes/LeakDetector/LeakDetector.swift index 8e896b0..0f797aa 100644 --- a/RIBs/Classes/LeakDetector/LeakDetector.swift +++ b/RIBs/Classes/LeakDetector/LeakDetector.swift @@ -54,7 +54,12 @@ public protocol LeakDetectionHandle { public class LeakDetector { /// The singleton instance. - public static let instance = LeakDetector() + public static private(set) var instance = LeakDetector() + + // This is used internally to be able to set mock instance in unit-tests. The public API and behavior of the public static LeakDetector instance above does not change. + static func setInstance(_ newInstance: LeakDetector) { + instance = newInstance + } /// The status of leak detection. /// @@ -170,8 +175,6 @@ public class LeakDetector { } return LeakDetector.disableLeakDetectorOverride }() - - private init() {} } fileprivate class LeakDetectionHandleImpl: LeakDetectionHandle { diff --git a/RIBsTests/LeakDetector/LeakDetectorMock.swift b/RIBsTests/LeakDetector/LeakDetectorMock.swift new file mode 100644 index 0000000..dc767b4 --- /dev/null +++ b/RIBsTests/LeakDetector/LeakDetectorMock.swift @@ -0,0 +1,39 @@ +// +// LeakDetectorMock.swift +// RIBs +// +// Created by Alex Bush on 7/26/25. +// + +@testable import RIBs +import Foundation +import RxSwift +import UIKit + +final class LeakDetectionHandleMock: LeakDetectionHandle { + var cancelCallCount = 0 + func cancel() { + cancelCallCount += 1 + } +} + +final class LeakDetectorMock: LeakDetector { + + var expectDeallocateCallCount = 0 + override func expectDeallocate(object: AnyObject, inTime time: TimeInterval) -> LeakDetectionHandle { + expectDeallocateCallCount += 1 + return LeakDetectionHandleMock() + } + + var expectViewControllerDisappearCallCount = 0 + override func expectViewControllerDisappear(viewController: UIViewController, inTime time: TimeInterval) -> LeakDetectionHandle { + expectViewControllerDisappearCallCount += 1 + return LeakDetectionHandleMock() + } + + var statusCallCount = 0 + override var status: Observable { + statusCallCount += 1 + return super.status + } +} diff --git a/RIBsTests/Router/RouterTests.swift b/RIBsTests/Router/RouterTests.swift new file mode 100644 index 0000000..2ab8a20 --- /dev/null +++ b/RIBsTests/Router/RouterTests.swift @@ -0,0 +1,180 @@ +// +// Copyright (c) 2017. Uber Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import RxSwift +import XCTest +@testable import RIBs + +final class RouterMock: Routing { + + var interactable: Interactable + + var children: [Routing] + + init(interactor: Interactable) { + self.interactable = interactor + self.children = [] + } + + var attachChildCallCount = 0 + func attachChild(_ child: Routing) { + attachChildCallCount += 1 + } + + var detachChildCallCount = 0 + func detachChild(_ child: Routing) { + detachChildCallCount += 1 + } + + var lifecycle: Observable { + return Observable.just(.didLoad) + } + + var loadCallCount = 0 + func load() { + loadCallCount += 1 + } +} + +final class RouterTests: XCTestCase { + + private var router: Router! + private var lifecycleDisposable: Disposable = Disposables.create() + private var leakDetectorMock: LeakDetectorMock = LeakDetectorMock() + + // MARK: - Setup + + override func setUp() { + super.setUp() + + leakDetectorMock = LeakDetectorMock() + LeakDetector.setInstance(leakDetectorMock) + + } + + override func tearDown() { + super.tearDown() + + lifecycleDisposable.dispose() + } + + // MARK: - Tests + + func test_load_verifyLifecycleObservable() { + router = Router(interactor: InteractableMock()) + var currentLifecycle: RouterLifecycle? + var didComplete = false + lifecycleDisposable = router + .lifecycle + .subscribe(onNext: { lifecycle in + currentLifecycle = lifecycle + }, onCompleted: { + currentLifecycle = nil + didComplete = true + }) + + XCTAssertNil(currentLifecycle) + XCTAssertFalse(didComplete) + + router.load() + + XCTAssertEqual(currentLifecycle, RouterLifecycle.didLoad) + XCTAssertFalse(didComplete) + + router = nil + + XCTAssertNil(currentLifecycle) + XCTAssertTrue(didComplete) + } + + func test_attachChild() { + // given + router = Router(interactor: InteractableMock()) + let mockChildInteractor = InteractableMock() + let mockChildRouter = RouterMock(interactor: mockChildInteractor) + + // when + router.attachChild(mockChildRouter) + + // then + XCTAssertEqual(router.children.count, 1) + XCTAssertEqual(mockChildInteractor.activateCallCount, 1) + XCTAssertEqual(mockChildRouter.loadCallCount, 1) + } + + func test_attachChild_activatesSubtreeOfTheChild() { + // given + router = Router(interactor: InteractableMock()) + let childInteractor = InteractableMock() + let childRouter = Router(interactor: childInteractor) + let grandChildInteractor = InteractableMock() + let grandChildRouter = RouterMock(interactor: grandChildInteractor) + childRouter.attachChild(grandChildRouter) + router.load() + + // when + router.attachChild(childRouter) + + // then + XCTAssertEqual(grandChildInteractor.activateCallCount, 1) + XCTAssertEqual(grandChildRouter.loadCallCount, 1) + } + + func test_detachChild() { + // given + router = Router(interactor: InteractableMock()) + let mockChildInteractor = InteractableMock() + let mockChildRouter = RouterMock(interactor: mockChildInteractor) + router.attachChild(mockChildRouter) + + // when + router.detachChild(mockChildRouter) + + // then + XCTAssertEqual(router.children.count, 0) + XCTAssertEqual(mockChildInteractor.deactivateCallCount, 1) + } + + func test_detachChild_deactivatesSubtreeOfTheChild() { + // given + router = Router(interactor: InteractableMock()) + let childInteractor = Interactor() + let childRouter = Router(interactor: childInteractor) + let grandChildInteractor = InteractableMock() + let grandChildRouter = RouterMock(interactor: grandChildInteractor) + router.load() + router.attachChild(childRouter) + childRouter.attachChild(grandChildRouter) + grandChildInteractor.isActive = true + + // when + router.detachChild(childRouter) + + // then + XCTAssertEqual(grandChildInteractor.deactivateCallCount, 1) + } + + func test_deinit_triggers_leakDetection() { + // given + let interactor = InteractableMock() + router = Router(interactor: interactor) + router.load() + // when + router = nil + // then + XCTAssertEqual(leakDetectorMock.expectDeallocateCallCount, 1) + } +} diff --git a/RIBsTests/Router/ViewableRouterTests.swift b/RIBsTests/Router/ViewableRouterTests.swift new file mode 100644 index 0000000..88d3f0e --- /dev/null +++ b/RIBsTests/Router/ViewableRouterTests.swift @@ -0,0 +1,56 @@ +// +// ViewableRouterTests.swift +// RIBs +// +// Created by Alex Bush on 7/26/25. +// + +import RxSwift +import XCTest +@testable import RIBs +import CwlPreconditionTesting + + +final class ViewControllerMock: ViewControllable { + + var uiviewController: UIViewController { + return UIViewController() + } +} + +final class ViewableRouterTests: XCTestCase { + + private var router: ViewableRouter, ViewControllerMock>! + private var leakDetectorMock: LeakDetectorMock = LeakDetectorMock() + + override func setUp() { + super.setUp() + + leakDetectorMock = LeakDetectorMock() + LeakDetector.setInstance(leakDetectorMock) + } + + func test_leakDetection() { + // given + let interactor = PresentableInteractor(presenter: PresenterMock()) + let viewController = ViewControllerMock() + router = ViewableRouter(interactor: interactor, viewController: viewController) + router.load() + // when + interactor.deactivate() + // then + XCTAssertEqual(leakDetectorMock.expectViewControllerDisappearCallCount, 1) + } + + func test_deinit_triggers_leakDetection() { + // given + let interactor = PresentableInteractor(presenter: PresenterMock()) + let viewController = ViewControllerMock() + router = ViewableRouter(interactor: interactor, viewController: viewController) + router.load() + // when + router = nil + // then + XCTAssertEqual(leakDetectorMock.expectDeallocateCallCount, 2) + } +} diff --git a/RIBsTests/RouterTests.swift b/RIBsTests/RouterTests.swift deleted file mode 100644 index d31d0c5..0000000 --- a/RIBsTests/RouterTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// Copyright (c) 2017. Uber Technologies -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import RxSwift -import XCTest -@testable import RIBs - -final class RouterTests: XCTestCase { - - private var router: Router! - private var lifecycleDisposable: Disposable! - - // MARK: - Setup - - override func setUp() { - super.setUp() - - router = Router(interactor: InteractableMock()) - } - - override func tearDown() { - super.tearDown() - - lifecycleDisposable.dispose() - } - - // MARK: - Tests - - func test_load_verifyLifecycleObservable() { - var currentLifecycle: RouterLifecycle? - var didComplete = false - lifecycleDisposable = router - .lifecycle - .subscribe(onNext: { lifecycle in - currentLifecycle = lifecycle - }, onCompleted: { - currentLifecycle = nil - didComplete = true - }) - - XCTAssertNil(currentLifecycle) - XCTAssertFalse(didComplete) - - router.load() - - XCTAssertEqual(currentLifecycle, RouterLifecycle.didLoad) - XCTAssertFalse(didComplete) - - router = nil - - XCTAssertNil(currentLifecycle) - XCTAssertTrue(didComplete) - } -}