diff --git a/Examples/SignUpDemo/Sources/Onboarding/OnboardingCoordinator.swift b/Examples/SignUpDemo/Sources/Onboarding/OnboardingCoordinator.swift index 3e07f52..bbd711e 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/OnboardingCoordinator.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/OnboardingCoordinator.swift @@ -12,6 +12,36 @@ public enum OnboardingNavigationStep: Sendable { /// Owns the SignUp scene. Currently the entire onboarding is one screen, but /// keeping it behind a coordinator means new steps (e.g. terms-of-service, /// email verification) can slot in here without touching `AppCoordinator`. +/// +/// ## Unit-testing the routing closure +/// +/// The trailing closure passed to `push(...)` below is captured by NVC's +/// internal Combine subscription. Tests that want to assert "when the user +/// finishes signing up, `OnboardingCoordinator` bubbles `.finishedOnboarding` +/// up to its parent" would otherwise have to drive the full view → view-model +/// → navigator → Combine pipeline through UIKit. +/// +/// NVC exposes the same closure on the scene controller as +/// ``NanoViewController/navigationHandler`` under `@_spi(Testing)`, so tests +/// can drive routing directly: +/// +/// ```swift +/// @_spi(Testing) import NanoViewControllerController +/// +/// func test_signUpFinish_bubblesFinishedOnboarding() { +/// let nav = UINavigationController() +/// let coordinator = OnboardingCoordinator(navigationController: nav, service: StubService()) +/// var bubbledStep: OnboardingNavigationStep? +/// coordinator.navigator.navigation.sink { bubbledStep = $0 }.store(in: &cancellables) +/// +/// coordinator.start() +/// let scene = nav.viewControllers.first as! SignUpScene +/// scene.navigationHandler?(.signedUp(SignedUpUser(id: "u1", name: "Test", email: "t@x"))) +/// pumpMainRunLoop() +/// +/// guard case .finishedOnboarding = bubbledStep else { return XCTFail("...") } +/// } +/// ``` public final class OnboardingCoordinator: BaseCoordinator { private let service: SignUpServicing diff --git a/README.md b/README.md index 99bd6b4..1e18a92 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,64 @@ The package ships six independent SPM library targets so consumers can pick exac `Combine`, `Navigation`, `Controller`, `SceneViews`, `DIPrimitives` all depend on `Core`. UIKit modules (`Controller`, `SceneViews`, `DIPrimitives`) need iOS 26+; pure value-type modules build on macOS 14+ too. Every target compiles in Swift 6.2 language mode (`swift-tools-version: 6.2`) so the package's `@MainActor`-on-UIKit annotations are enforced as hard errors at the consumer's call site. +# Testing your coordinators + +Coordinators in NVC register routing logic as a trailing closure to `push(...)` / `modallyPresent(...)`: + +```swift +push(scene: PrepareScene.self, viewModel: vm) { [weak self] step in + guard let self else { return } + switch step { + case .cancel: finish() + case let .submit(payment): toReviewPayment(payment) + } +} +``` + +That closure is invoked through a Combine subscription on the scene's `navigation` publisher — driving it from a unit test would normally mean taking the long way around: a ViewModel-level test that drives the view's input subjects through real UIKit (`tap`, `setText`, `drainRunLoop`) so the ViewModel's `transform` finally emits the step, the subscription fires, and the closure runs. + +NVC exposes the closure directly on each `NanoViewController` instance under an `@_spi(Testing)` seam so unit tests can short-circuit the pipeline: + +```swift +@_spi(Testing) import NanoViewControllerController + +func test_submitPayment_pushesReviewTransaction() throws { + // Arrange + let coordinator = SendCoordinator(navigationController: nav, deeplinks: Empty().eraseToAnyPublisher()) + coordinator.start() + let prepare = try XCTUnwrap(nav.viewControllers.first as? PrepareScene) + + // Act + prepare.navigationHandler?(.submit(payment)) + + // Assert + XCTAssertTrue(nav.viewControllers.last is ReviewScene) +} +``` + +Two hooks ship on every `NanoViewController`: + +| Hook | Set by | Closure shape | +|---|---|---| +| `scene.navigationHandler` | `push(...)` / `pushSceneInstance(...)` | `(NavigationStep) -> Void` | +| `scene.modalNavigationHandler` | `modallyPresent(...)` / `replaceAllScenes(...)` | `(NavigationStep, DismissScene) -> Void` | + +The modal variant accepts a spy `DismissScene` so tests can observe both the routing and the dismissal: + +```swift +var dismissCalled = false +let spy: DismissScene = { _, completion in + dismissCalled = true + completion?() +} +scan.modalNavigationHandler?(.scanned(intent), spy) +XCTAssertTrue(dismissCalled) +``` + +**Trade-off.** Driving the SPI handler directly does not assert that the ViewModel's emitted step actually reaches the coordinator's subscription — only that the handler routes correctly once invoked. The Combine wiring is identical across every `push(...)` / `modallyPresent(...)` call, so a single happy-path UI-driven test (or simply observing that the scene appears on the stack after `start()`) is enough to cover it. Use the SPI for the per-step routing assertions; rely on ViewModel-level tests for the emission contracts. + +**Visibility.** The two hooks are `@_spi(Testing) public internal(set)` — production callers don't see them unless they opt in with `@_spi(Testing) import NanoViewControllerController`. Only NVC writes through them; consumers can only read. + # Local development First-time setup on a fresh clone: diff --git a/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift index aad46fd..1461640 100644 --- a/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift @@ -11,10 +11,17 @@ extension Coordinating { /// /// Used by the push-style hookup in /// ``Coordinating/pushSceneInstance(_:animated:navigationPresentationCompletion:navigationHandler:)``. + /// + /// Side effect: stashes `handler` on + /// ``NanoViewController/navigationHandler`` (an `@_spi(Testing)` hook) so + /// tests can invoke coordinator routing directly without driving the + /// view-model's Combine pipeline. Production callers don't see this + /// property unless they opt in via `@_spi(Testing) import`. func subscribeToNavigation, V: ContentView>( of scene: S, handler: @escaping (V.ViewModel.NavigationStep) -> Void ) { + scene.navigationHandler = handler scene.navigation .sinkOnMain { handler($0) } .store(in: &cancellables) @@ -25,10 +32,16 @@ extension Coordinating { /// the controller with optional animation. Used by the modal-style hookups in /// ``Coordinating/modallyPresent(scene:animated:presentationCompletion:navigationHandler:)`` /// and ``Coordinating/replaceAllScenes(with:animated:whenReplacingFinished:navigationHandler:)``. + /// + /// Side effect: stashes `handler` on + /// ``NanoViewController/modalNavigationHandler`` (an `@_spi(Testing)` hook). + /// Tests pass a spy ``DismissScene`` when invoking the handler so the + /// dismissal side-effect is observable without a real modal presentation. func subscribeToModalNavigation, V: ContentView>( of scene: S, handler: @escaping ModalNavigationHandler ) { + scene.modalNavigationHandler = handler scene.navigation .sinkOnMain { [weak scene] step in handler(step) { animated, completion in diff --git a/Sources/NanoViewControllerController/NanoViewController.swift b/Sources/NanoViewControllerController/NanoViewController.swift index b29f571..c789512 100644 --- a/Sources/NanoViewControllerController/NanoViewController.swift +++ b/Sources/NanoViewControllerController/NanoViewController.swift @@ -3,6 +3,7 @@ import Combine import NanoViewControllerCore import NanoViewControllerDIPrimitives +import NanoViewControllerNavigation import UIKit /// The "Single-Line Controller" base class — generic scene glue that hosts @@ -99,6 +100,111 @@ open class NanoViewController: UIViewController { public lazy var navigation: AnyPublisher = navigationSubject.eraseToAnyPublisher() + /// Test-only handle to the navigation handler the coordinator registered + /// for this scene when it was pushed onto the navigation stack via + /// ``Coordinating/push(scene:viewModel:animated:navigationPresentationCompletion:navigationHandler:)`` + /// or + /// ``Coordinating/pushSceneInstance(_:animated:navigationPresentationCompletion:navigationHandler:)``. + /// + /// ## Why this exists + /// + /// Coordinators register routing logic inline at the call site: + /// + /// ```swift + /// push(scene: PrepareScene.self, viewModel: vm) { [weak self] step in + /// guard let self else { return } + /// switch step { + /// case .cancel: finish() + /// case let .submit(payment): toReviewPayment(payment) + /// } + /// } + /// ``` + /// + /// That handler is normally only invoked through the Combine pipeline: + /// the ViewModel emits a `NavigationStep` from `transform`, NVC forwards + /// it through ``navigation``, and the coordinator's `sinkOnMain` + /// subscription dispatches it. Unit tests that just want to assert + /// **"when `.submit(payment)` happens, `ReviewPayment` gets pushed"** + /// would otherwise have to drive the full view → view-model → Combine + /// chain via UIKit (taps, text entry, runloop drains) to reach that + /// switch statement. + /// + /// `navigationHandler` exposes the same closure that the Combine sink is + /// holding, so tests can synthesize the step directly: + /// + /// ```swift + /// @_spi(Testing) import NanoViewControllerController + /// + /// func test_submitPayment_pushesReview() { + /// coordinator.start() + /// let prepare = navigationController.viewControllers.first as! PrepareScene + /// + /// prepare.navigationHandler?(.submit(payment)) + /// + /// XCTAssertTrue(navigationController.viewControllers.last is ReviewScene) + /// } + /// ``` + /// + /// ## Trade-off + /// + /// The test no longer verifies that the ViewModel's emitted step actually + /// reaches the coordinator's subscription — only that the handler routes + /// correctly once invoked. The wiring is identical across every `push` + /// call site, so one happy-path test that drives a real Combine emission + /// is usually enough to cover the subscription itself. Use this seam for + /// the routing-logic tests; rely on VM-level tests for the emission + /// contracts and a UI smoke test for the pipeline. + /// + /// ## Visibility + /// + /// Hidden behind `@_spi(Testing)` — production callers see this property + /// as `internal` unless they opt in via `@_spi(Testing) import`. NVC and + /// its tests can write through `internal(set)`; consumers can only read. + /// + /// Nil when the scene was presented modally (see ``modalNavigationHandler``) + /// or hasn't been pushed yet. + @_spi(Testing) + public internal(set) var navigationHandler: ((ViewModel.NavigationStep) -> Void)? + + /// Test-only handle to the navigation handler the coordinator registered + /// for this scene when it was presented modally via + /// ``Coordinating/modallyPresent(scene:animated:presentationCompletion:navigationHandler:)`` + /// (or one of the modal-style overloads such as + /// ``Coordinating/replaceAllScenes(with:animated:whenReplacingFinished:navigationHandler:)``). + /// + /// Same rationale as ``navigationHandler``, but the modal variant's + /// handler signature carries an extra ``DismissScene`` parameter so the + /// coordinator can dismiss the modal from inside the routing closure: + /// + /// ```swift + /// modallyPresent(scene: ScanQRCode.self, viewModel: vm) { [weak self] step, dismiss in + /// switch step { + /// case let .scanned(intent): + /// dismiss(true) { self?.parent.forward(intent) } + /// case .cancel: + /// dismiss(true, nil) + /// } + /// } + /// ``` + /// + /// In tests, pass a spy ``DismissScene`` to observe both the routing + /// and the dismissal: + /// + /// ```swift + /// var dismissCalled = false + /// let dismiss: DismissScene = { _, completion in + /// dismissCalled = true + /// completion?() + /// } + /// scan.modalNavigationHandler?(.scanned(intent), dismiss) + /// XCTAssertTrue(dismissCalled) + /// ``` + /// + /// Nil when the scene was pushed (see ``navigationHandler``) or hasn't + /// been presented yet. + @_spi(Testing) + public internal(set) var modalNavigationHandler: ((ViewModel.NavigationStep, @escaping DismissScene) -> Void)? + /// Clock used to auto-dismiss toasts emitted via /// ``InputFromController/toastSubject``. /// diff --git a/Tests/NanoViewControllerControllerTests/NavigationHandlerSPITests.swift b/Tests/NanoViewControllerControllerTests/NavigationHandlerSPITests.swift new file mode 100644 index 0000000..962ddb4 --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/NavigationHandlerSPITests.swift @@ -0,0 +1,197 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine +@_spi(Testing) @testable import NanoViewControllerController +import NanoViewControllerCore +import NanoViewControllerNavigation +import UIKit +import XCTest + +/// Tests for the `@_spi(Testing)` navigation-handler hooks on +/// ``NanoViewController``. The hooks let unit tests drive coordinator +/// routing without going through the view-model's Combine pipeline (no UI +/// taps, no text entry, no runloop drains). +/// +/// These tests verify three contracts: +/// +/// 1. After `push(...)`, `scene.navigationHandler` is set, callable, and +/// routes through the exact same closure the Combine subscription uses. +/// 2. After `modallyPresent(...)`, `scene.modalNavigationHandler` is set +/// and forwards both the step *and* a caller-provided `DismissScene`. +/// 3. The push/modal hooks are mutually exclusive — a scene that was +/// pushed has a nil `modalNavigationHandler`, and vice versa. +@MainActor +final class NavigationHandlerSPITests: XCTestCase { + // MARK: - push(...) — navigationHandler + + func test_pushScene_setsNavigationHandlerOnTheScene() { + // Arrange + let coordinator = TestCoordinator(navigationController: UINavigationController()) + let viewModel = SPITestViewModel() + var routedSteps = [SPITestStep]() + + // Act + coordinator.push(scene: SPITestScene.self, viewModel: viewModel, animated: false) { + routedSteps.append($0) + } + + // Assert — the scene exposes the same closure the Combine sink will fire. + let scene = coordinator.navigationController.viewControllers.last as? SPITestScene + XCTAssertNotNil(scene?.navigationHandler) + XCTAssertNil(scene?.modalNavigationHandler) + + scene?.navigationHandler?(.alpha) + + XCTAssertEqual(routedSteps, [.alpha]) + } + + func test_pushScene_navigationHandlerAndCombineSinkRouteToSameClosure() { + // Arrange — same handler should fire whether invoked directly via the + // SPI hook or via the ViewModel's emitted step. + let coordinator = TestCoordinator(navigationController: UINavigationController()) + let viewModel = SPITestViewModel() + var routedSteps = [SPITestStep]() + + coordinator.push(scene: SPITestScene.self, viewModel: viewModel, animated: false) { + routedSteps.append($0) + } + let scene = coordinator.navigationController.viewControllers.last as? SPITestScene + + // Act — fire the handler directly, then drive the VM through its real input. + scene?.navigationHandler?(.alpha) + viewModel.trigger.send(.beta) + pumpMainRunLoop() + + // Assert — both pulses landed on the same coordinator-side handler. + XCTAssertEqual(routedSteps, [.alpha, .beta]) + } + + // MARK: - modallyPresent(...) — modalNavigationHandler + + func test_modallyPresent_setsModalNavigationHandlerOnTheScene() { + // Arrange + let nav = ModalPresentCapturingNavigationController() + let coordinator = TestCoordinator(navigationController: nav) + let viewModel = SPITestViewModel() + var routedSteps = [SPITestStep]() + var dismissCallCount = 0 + + // Act + coordinator.modallyPresent(scene: SPITestScene.self, viewModel: viewModel, animated: false) { step, dismiss in + routedSteps.append(step) + dismiss(false, nil) + } + let presented = nav.presentedViewControllerCapture as? UINavigationController + let scene = presented?.viewControllers.first as? SPITestScene + + // Assert — modal-shaped handler is present; the push-shaped one is not. + XCTAssertNotNil(scene?.modalNavigationHandler) + XCTAssertNil(scene?.navigationHandler) + + // Act 2 — invoke the SPI handler with a spy dismiss closure. + let spyDismiss: DismissScene = { _, _ in dismissCallCount += 1 } + scene?.modalNavigationHandler?(.gamma, spyDismiss) + + // Assert + XCTAssertEqual(routedSteps, [.gamma]) + XCTAssertEqual( + dismissCallCount, + 1, + "modalNavigationHandler should forward the caller's DismissScene to the routing closure" + ) + } + + func test_modalNavigationHandler_dismissForwardsAnimatedFlagAndCompletion() { + // Arrange + let nav = ModalPresentCapturingNavigationController() + let coordinator = TestCoordinator(navigationController: nav) + let viewModel = SPITestViewModel() + var observedAnimated: Bool? + var observedCompletionCalled = false + + coordinator.modallyPresent(scene: SPITestScene.self, viewModel: viewModel, animated: false) { _, dismiss in + // Routing closure asks for an animated dismiss with a completion. + dismiss(true) { observedCompletionCalled = true } + } + let presented = nav.presentedViewControllerCapture as? UINavigationController + let scene = presented?.viewControllers.first as? SPITestScene + + // Act — invoke the SPI handler with a spy that records the args. + let spy: DismissScene = { animated, completion in + observedAnimated = animated + completion?() + } + scene?.modalNavigationHandler?(.gamma, spy) + + // Assert + XCTAssertEqual(observedAnimated, true) + XCTAssertTrue(observedCompletionCalled) + } +} + +// MARK: - Test doubles + +private enum SPITestStep: Sendable, Equatable { + case alpha + case beta + case gamma +} + +private struct SPITestInputFromView {} +private struct SPITestPublishers {} + +private final class SPITestViewModel: AbstractViewModel { + let trigger = PassthroughSubject() + + override func transform(input _: Input) -> Output { + let navigator = Navigator() + return Output( + publishers: SPITestPublishers(), + navigation: navigator.navigation + ) { + trigger.sink { [navigator] in navigator.next($0) } + } + } +} + +private final class SPITestView: UIView, ViewModelled { + typealias ViewModel = SPITestViewModel + + var inputFromView: SPITestInputFromView { + SPITestInputFromView() + } + + init() { + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + interfaceBuilderSucks + } +} + +private final class SPITestScene: NanoViewController {} + +private enum SPICoordinatorStep: Sendable { + case routed +} + +private final class TestCoordinator: BaseCoordinator { + override func start(didStart: Completion? = nil) { + didStart?() + } +} + +private final class ModalPresentCapturingNavigationController: UINavigationController { + private(set) var presentedViewControllerCapture: UIViewController? + + override func present( + _ viewControllerToPresent: UIViewController, + animated _: Bool, + completion: (() -> Void)? = nil + ) { + presentedViewControllerCapture = viewControllerToPresent + completion?() + } +}