Navigating プロトコル導入でハンドラのテスト容易化
Navigating プロトコルを導入し、ルート・ウェブビュー方針ハンドラが具体的な Navigator へ依存しない抽象インタフェースを利用できるようにしました。これによりテストコードは軽量なスタブでハンドラロジックを検証でき、実装詳細の生成コストが除去されます。
背景
ハンドラが Navigator に直接依存していた ため、ユニットテストでは Session や WKWebView の実体を生成する必要があり、テストが重くなっていました。ハンドラが実際に使用するのは route, reload, activeNavigationController などごく一部の操作だけでした。抽象化できる部分を切り出すことで、テストダブルが実装詳細を偽装する負担をなくすことが目的です。
技術的な変更
インタフェースの拡張 と 型置換 が中心です。NavigationHandler にオプション付き route(_:options:parameters:) を追加し、従来の route(_:) はデフォルト実装で委譲する形にしました。
public protocol NavigationHandler: AnyObject {
/// Routes to the given URL.
/// - Parameters:
/// - url: the URL to visit.
/// - options: passed options will override default `advance` visit options.
/// - parameters: provide context relevant to `url`.
func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?)
func route(_ proposal: VisitProposal)
}
public extension NavigationHandler {
/// Routes to the given URL using the default `advance` visit options.
func route(_ url: URL) {
route(url, options: VisitOptions(action: .advance), parameters: nil)
}
}
extension Navigator: NavigationHandler {}
新規プロトコル Navigating を追加し、NavigationHandler を継承させてハンドラが必要とする操作群を抽象化しました。pop, clearAll, reload と主要ナビゲーションコントローラへのアクセスプロパティ、そして便利プロパティ currentVisitableURL を提供します。
public protocol Navigating: NavigationHandler {
func pop(animated: Bool)
func clearAll(animated: Bool)
func reload()
var rootViewController: UINavigationController { get }
var modalRootViewController: UINavigationController { get }
var activeNavigationController: UINavigationController { get }
var currentVisitableURL: URL? { get }
}
public extension Navigating {
var currentVisitableURL: URL? {
(activeNavigationController.topViewController as? VisitableViewController)?.currentVisitableURL
}
}
extension Navigator: Navigating {}
ハンドラとマネージャのシグネチャ を Navigator から Navigating へ置換しました。RouteDecisionHandler, WebViewPolicyDecisionHandler, Router などの navigator パラメータ型が変更され、ロジック自体は変わっていません。
public protocol RouteDecisionHandler {
func handle(location: URL,
configuration: Navigator.Configuration,
navigator: Navigating) -> Router.Decision
}
同様に各ハンドラ実装(例: AppNavigationRouteDecisionHandler, SafariViewControllerRouteDecisionHandler, ExternalNavigationWebViewPolicyDecisionHandler など)でも型名が置換されています。差分は Navigator → Navigating の1行変更に留まります。
テスト用スタブ NavigationSpy を Navigating に準拠した軽量クラスとして実装しました。Session や WKWebView を生成せず、呼び出しの記録のみを行います。
final class NavigationSpy: Navigating {
var routeWasCalled = false
var routeURL: URL?
var reloadWasCalled = false
var currentVisitableURL: URL?
lazy var rootViewController = UINavigationController()
lazy var modalRootViewController = UINavigationController()
lazy var activeNavigationController = UINavigationController()
func route(_ proposal: VisitProposal) {
routeWasCalled = true
routeURL = proposal.url
}
func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) {
routeWasCalled = true
routeURL = url
}
func pop(animated: Bool) {}
func clearAll(animated: Bool) {}
func reload() { reloadWasCalled = true }
}
HotwireTabBarController の route 実装 も新しいシグネチャに合わせて更新され、activeNavigator.route(url, options:options, parameters:parameters) を呼び出すようになりました。
open func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) {
activeNavigator.route(url, options: options, parameters: parameters)
}
設計判断
表面だけを公開し、実装詳細はプロトコルに含めない 方針が採用されています。session や modalSession など重量級の依存は Navigating に露出せず、テストダブルはそれらを偽装する必要がなくなります。これは「テスト可能な抽象は最小限で十分」という設計原則に合致します。
NavigationHandler の route(_:options:parameters:) を必須要件 とし、従来の route(_:) はデフォルト実装で提供したことで、既存コードへの影響を最小化しています。Navigator は追加実装なしで新プロトコルに適合でき、後方互換性が担保されています。
型置換はシグネチャの1行変更に留められ、ロジックはそのままです。これによりリリース時のリグレッションリスクが低減し、将来的な拡張やリファクタリングでも安全に抽象層を利用できます。
まとめ
Navigating プロトコルの導入で、ハンドラは具体的な Navigator ではなく抽象インタフェースに依存するようになり、テストが高速かつ軽量化されました。変更はインタフェース拡張と型置換に限定され、既存ナビゲーションロジックの挙動は保持されています。今後は軽量なスタブを用いたハンドラ単体テストが容易になることで、機能追加やリファクタリング時の安全性が向上します。