Navigating プロトコル導入でハンドラのテスト容易化

hotwired/hotwire-native-ios

Navigating プロトコルを導入し、ルート・ウェブビュー方針ハンドラが具体的な Navigator へ依存しない抽象インタフェースを利用できるようにしました。これによりテストコードは軽量なスタブでハンドラロジックを検証でき、実装詳細の生成コストが除去されます。

背景

ハンドラが Navigator に直接依存していた ため、ユニットテストでは SessionWKWebView の実体を生成する必要があり、テストが重くなっていました。ハンドラが実際に使用するのは 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 など)でも型名が置換されています。差分は NavigatorNavigating の1行変更に留まります。

テスト用スタブ NavigationSpyNavigating に準拠した軽量クラスとして実装しました。SessionWKWebView を生成せず、呼び出しの記録のみを行います。

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

HotwireTabBarControllerroute 実装 も新しいシグネチャに合わせて更新され、activeNavigator.route(url, options:options, parameters:parameters) を呼び出すようになりました。

open func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) {
    activeNavigator.route(url, options: options, parameters: parameters)
}

設計判断

表面だけを公開し、実装詳細はプロトコルに含めない 方針が採用されています。sessionmodalSession など重量級の依存は Navigating に露出せず、テストダブルはそれらを偽装する必要がなくなります。これは「テスト可能な抽象は最小限で十分」という設計原則に合致します。

NavigationHandlerroute(_:options:parameters:) を必須要件 とし、従来の route(_:) はデフォルト実装で提供したことで、既存コードへの影響を最小化しています。Navigator は追加実装なしで新プロトコルに適合でき、後方互換性が担保されています。

型置換はシグネチャの1行変更に留められ、ロジックはそのままです。これによりリリース時のリグレッションリスクが低減し、将来的な拡張やリファクタリングでも安全に抽象層を利用できます。

まとめ

Navigating プロトコルの導入で、ハンドラは具体的な Navigator ではなく抽象インタフェースに依存するようになり、テストが高速かつ軽量化されました。変更はインタフェース拡張と型置換に限定され、既存ナビゲーションロジックの挙動は保持されています。今後は軽量なスタブを用いたハンドラ単体テストが容易になることで、機能追加やリファクタリング時の安全性が向上します。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
9194b715

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文、背景、技術的な変更、設計判断(任意)、まとめの全要素が揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックのファイル名付きハイライトは正しい形式ですが、PRリンクの記法が `[ #246 ](URL)` とスペースが入っており、仕様の `[#246](URL)` と完全には一致していません。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

対象はiOS開発者向けで、専門用語が適切に使用されており、初心者向けの過度な説明はありません。

パラグラフ・ライティング ⚠ WARNING

トピックセンテンス・1段落1トピック・段落長

各セクションは総論パラグラフで始まっていますが、"技術的な変更" セクションに結論パラグラフが欠けており、セクション全体のまとめが不足しています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のコードブロックは提供されたDiffと一致しており、ファイル名・内容ともに正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

用語(Navigating, Navigator, route, VisitOptions など)はPRで使用されているものと一致し、誤用はありません。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的な説明はPRの内容と合致しており、根拠のない主張や誤りは見られません。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

全ての主張はPRタイトル、Description、Diffに裏付けられており、ハルシネーションはありません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号 #246 が正しく記載されており、その他の数値・固有名詞の誤りはありません。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR の趣旨「Navigating プロトコル導入でハンドラのテスト容易化」を正確に表現しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内で PR に記載されていない外部知識(LTS、EOL など)は使用されていません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現の歪曲はなく、PRの記述と一致しています。