この記事は、The Composable Architecture Advent Calendar 2022 12/20の記事です。
iOSエンジニアの金子です。
以前の記事でReducerProtocolが導入された背景について説明しましたが、今回はReducerProtocolになることでかなり便利になったDI周りについてご紹介したいと思います。
これまでの課題
TCAを採用したアーキテクチャにおいて、最も面倒だと思うものの一つに依存関係の更新作業がありました。
例えば、以下のようなコンポーネントの階層を持ったアプリを想定しましょう。
App ├── ChildFeature1 └── ChildFeature2 └── GrandChildFeature1 └── GrandChildFeature2
ここで、GrandChildFeature2に新しい依存関係を追加するとしましょう。
extension GrandChildFeature2 { struct Environment { ... let newsRepository: NewsRepository // 追加 init( ..., newsRepository: NewsRepository // 追加 ) { ... self.newsRepository = newsRepository // 追加 } } }
コンパイルできるようにするには、その親コンポーネントであるChildFeature2、そしてルートのコンポーネントのAppにも同じ依存関係を追加する必要があります。
extension ChildFeature2 { struct Environment { ... let newsRepository: NewsRepository // 追加 init( ..., newsRepository: NewsRepository // 追加 ) { ... self.newsRepository = newsRepository // 追加 } } static let reducer = Reducer<State, Action, Environment>.combine( GrandChildFeature2.reducer .pullback( state: \.grandChildFeature2, action: /Action.grandChildFeature2, environment: { .init( ..., newsRepository: $0.newsRepository // 追加 ) } ),
さらには、各Featureをプレビューするためのコードやテストコードにも影響が及びますし、階層がもっと深いところで依存関係の更新が発生すると、もはや地獄です。
僕らのチームでも、ちょっとした改修をするだけのタスクなのに、プルリクエストの変更ファイル数が多くなったり、コンフリクトするという問題が度々発生していました。
ReducerProtocolに移行すると、この問題が劇的に改善されます。
@DependencyプロパティラッパーによるDI
TCAの新しいDIは、SwiftUIのEnvironmentValuesに非常に似ています。
GrandChildFeature2に新しい依存を追加したいときは、以下のように@Dependencyプロパティラッパーを使って依存を定義します。
struct GrandChildFeature2: ReducerProtocol { @Dependency(\.newsRepository) var newsRepository ...
GrandChildFeature2を利用するときに、もはやイニシャライザに依存を渡す必要はありません。
struct ChildFeature2: ReducerProtocol { var body: some ReducerProtocol<State, Action> { Scope(state: \.grandChildFeature2, action: /Action.grandChildFeature2) { GrandChildFeature2() // 依存を渡さなくて良い! } ... } }
これは劇的な改善です!
末端のコンポーネントに新しい依存を追加したくなっても、もうストレスに感じることはなくなります。
プルリクエストのファイル変更数の多さにビックリすることもなくなるし、依存の追加によるコンフリクトに悩まされることもなくなりますね。
依存を登録する方法もシンプルです。
登録したいクラスなり構造体をDependencyKeyに準拠させます。
ここでNewsRepositoryはWeb APIからニュース記事情報を取ってくるリポジトリを想定しています。
liveValue
には実際のネットワーク通信を行うコードを持つNewsRepositoryのインスタンスを格納します。
そして、DependencyValuesのエクステンションを以下のように実装します。
extension NewsRepository: DependencyKey { static let liveValue = NewsRepository.live } extension DependencyValues { var newsRepository: NewsRepository { get { self[NewsRepository.self] } set { self[NewsRepository.self] = newValue } } }
長ったらしいボイラープレートを書くこともなく、とてもシンプルですね。
テストやXcodeプレビューのサポートもされている
テスタビリティはTCAの最優先事項である*1と明言しているだけに、テストのサポートもしっかりとされています。
前述のNewsRepositoryが以下のように実装されているとします(このNewsRepositoryの設計気になると思いますがそれについては後述)。
struct NewsRepository { var fetchNews: (id: Int) async throws -> News }
テストコードでは以下のように依存のエンドポイント(ここではfetchNews)をオーバーライドすることで、モックデータを返すように変更することができます。
@MainActor func testFetchNews() async { let store = TestStore( initialState: GrandChildFeature2.State(), reducer: GrandChildFeature2() ) // fetchNewsの実装を差し替える store.dependencies.newsRepository.fetchNews = { _ in News(id: 1, title: "ニュースのタイトル") } await store.send(.loadButtonTapped) await store.receive(.newsResponse(.success(News(id: 1, name: "ニュースのタイトル")))) { $0.loadedNews = News(id: 1, name: "ニュースのタイトル") } }
TestStoreによってDIされるので、GrandChildFeature2のイニシャライザに依存をあれこれと渡す必要がありません。
さらに、依存のエンドポイントにTestStoreインスタンス経由で簡単にアクセスでき、動作を簡単に書き換えられます。
テストコードはそのテストを行うための準備のコードがたくさんあって可読性が悪くなりがちですが、新しいDIの仕組みによってテストコードのボイラープレートをかなり減らすことができそうです。
依存のテスト・プレビュー用の実装
依存を登録するにはDependencyKeyに準拠させることを前述しましたが、DependencyKeyにはliveValue
の他に2つのオプショナルなプロパティが定義されています(実際にはDependencyKeyのベースプロトコルであるTestDependencyKeyのプロパティです)。
testValue
とpreviewValue
です。
testValue
を実装すると、TestStoreによってDIされた場合にその実装がテストで使用されることになります。
ただ、testValue
にモックデータを返すような実装をするのではなく、「unimplementedな依存」と呼ぶ実装をすることが強く推奨されています。
「unimplementedな依存」は、以下のようにエンドポイントが呼ばれたらXCTFailを実行してテストを失敗させるような実装を持たせたものです。
このような実装にしておくことで、ある機能をテストするときにその機能がどの依存を必要としているのかを明確にすることができます(テスト時にモック実装に置き換えることを強制するので、テストコードを見れば何に依存しているのかわかる)。
extension NewsRepository { static let testValue = Self( fetchNews: { _ in XCTFail("NewsRepository.fetchNews unimplemented") } ) }
なお、XCTFailはテストターゲットでしか利用できないので、このままだとコンパイルができません。
そこで、XCTestDynamicOverlayというライブラリを使用し、XCTFailをテスト時に動的に呼び出すように変えます。
import XCTestDynamicOverlay extension NewsRepository { static let testValue = Self( fetchNews: unimplemented("NewsRepository.fetchNews") ) }
previewValue
はXcodeプレビューで使用されます。
以下のように実装すると、プレビュー時にモックデータが返るようになります。
モックデータの内容を変えることで、条件に応じたビューの様々なパターンを確認することができます。
extension NewsRepository { static let previewValue = Self( fetchNews: { id in News(id: id, title: "ニュースのタイトル" } ) }
依存の設計
TCAのDIの仕組みにより、依存の実装をプロダクション、テスト、プレビューそれぞれの環境用に簡単に切り替えられることがわかりました。
さらに一歩進んで、非常に高い柔軟性を持った依存の設計方法についてご紹介します。
依存の設計における最もポピュラーな方法はProtocolを使ったものだと思います。
例えば、ReducerをWeb APIやDBといった外部システムとやり取りするクライアントに直接依存させるのではなく、Protocolに依存させることで、テスト時はこれらのクライアントをモック化してテストしやすくすることができます。
NewsPicksのiOSアプリにおいても、ReducerはRepositoryプロトコルに依存させていて、プロダクション環境ではRepositoryImpl(実際にWeb APIにリクエストするオブジェクト)、テスト環境ではMockRepository(モックデータを返すオブジェクト)をインジェクションするやり方をしてきました。
これは十分機能しますが、以下のような課題がありました。
- テストとXcodeプレビュー用に、それぞれモック実装が必要
- テスト時に各種検証を行うコードの実装が必要(ここはライブラリを利用することが多い)
- プレビューでビューの様々なスタイルを確認するために、通信成功を表現するモック、エラーを表現するモックなど複数用意するのが面倒
一言で言えば、モックの管理が面倒ということです。
NewsPicksアプリは非常に多機能なので、Web APIのエンドポイント数も膨大です。
なので必然的にモックの数も多くなり、メンテコストが地味にかかっていました。
TCAが推奨するstructを使った設計方法を採用すると、以下のメリットが得られます。
- XCTestDynamicOverlayが使えるのでテストおよびプレビュー用の仮実装がめちゃくちゃ楽
- テストおよびプレビュー時の実装の差し替えがめちゃくちゃ楽
前述のNewsRepositoryを再び例にしましょう。
従来のプロトコルを使った実装はこんな感じになるでしょう。
public protocol NewsRepository { func fetchNewsList() async throws -> NewsList func fetchNews(id: Int) async throws -> News } public struct MockNewsRepository: NewsRepository { public func fetchNewsList() async throws -> NewsList { NewsList([News(id: 1, title: "ニュースのタイトル")]) } public func fetchNews(id: Int) async throws -> News { News(id: 1, title: "ニュースのタイトル") } } public enum NewsRepositoryKey: TestDependencyKey { public static let testValue: any NewsRepository = MockNewsRepository() public static let previewValue: any NewsRepository = MockNewsRepository() }
この例はメソッドも少ないし、Newsのデータ構造もシンプルなので大したコードではないですが、実際のアプリではメソッドも多くデータ構造も複雑なので実装するのはやや手間です。
また、テストを実装するためにモックデータをいじったら他のテストが壊れた、なんてことも発生しがちです。
モック実装を工夫することで防ぐことができるかもしれませんが、汎用的なモックの実装はそれはそれでまたコストがかかります。
structを使った設計にするとどうなるでしょうか?
XCTestDynamicOverlayのunimplementedを使うことで、具体的な実装をする必要がなくなります。
さらに、テスト時にモック実装に置き換えることを強制できるので、モックをいじったらテストが壊れた、といった問題も発生しないでしょう。
public struct NewsRepository { public var fetchNewsList: () async throws -> NewsList public var fetchNews: (_ id: Int) async throws -> News } extension NewsRepository: TestDependency { public static let testValue: NewsRepository = .init( fetchNewsList: unimplemented("\(Self.self).fetchNewsList"), fetchNews: unimplemented("\(Self.self).fetchNews"), ) public static let previewValue: NewsRepository = .init( fetchNewsList: unimplemented("\(Self.self).fetchNewsList"), fetchNews: unimplemented("\(Self.self).fetchNews"), ) }
また、これは公式Docでも言及されていますが*2、Featureが依存する対象を最小限にすることができます。
structを使った場合、FeatureはNewsRepositoryのfetchNewsだけに依存させることができます。
プロトコルではこれができず、NewsRepositoryプロトコルに対して依存させることになります。
// structを使った場合 struct Feature: ReducerProtocol { @Dependency(\.newsRepository.fetchNews) var fetchNews } // プロトコルを使った場合 struct Feature: ReducerProtocol { @Dependency(\.newsRepository) var newsRepository }
この設計では、Featureの依存を最小限にするというだけでなく、プレビューやテスト時のモックの差し替えも非常に簡単にできるというメリットがあります。
@MainActor func testFetchNews() async { let store = TestStore( initialState: GrandChildFeature2.State(), reducer: GrandChildFeature2() ) // fetchNewsの差し替えが簡単 store.dependencies.newsRepository.fetchNews = { _ in News(id: 1, title: "ニュースのタイトル") } // プロトコルの場合はNewsRepositoryの実装全体を差し替えることになる // 差し替え自体は以下のように単純だが、正常系のモック、異常系のモックなど様々なモック実装を用意する必要がある // store.dependencies.newsRepository = MockNewsRepository() await store.send(.loadButtonTapped) await store.receive(.newsResponse(.success(News(id: 1, name: "ニュースのタイトル")))) { $0.loadedNews = News(id: 1, name: "ニュースのタイトル") } }
XcodeプレビューではReducerProtocolのdependency(::)/)メソッドを使用するとプレビュー作業が捗りそうです。
このメソッドは依存を上書きするためのもので、以下のようにfetchNews
が返す値を色々と変えたり、エラーを返すようにしたりできるので、プレビューで様々なビューのスタイルを確認することが簡単にできます。
struct GrandChildFeature2View_Previews: PreviewProvider { static var previews: some View { GrandChildFeature2View( store: .init( initialState: .init(), reducer: GrandChildFeature2() .dependency( \.newsRepository.fetchNews, { _ in News(id: 2, title: "ニュースのタイトル2") } // fetchNewsが返すデータを差し替える ) ) ) } }
structを使った依存の設計については、Point-Freeの動画で詳しく解説されています。
こちらも参照するとより理解が深まるでしょう。
マルチモジュール構成における依存の実装方法
最後に、マルチモジュール構成における依存実装のプラクティスをご紹介します。
あるFeatureが、Web APIからニュース情報を取得するためにNewsRepositoryに依存しているとします。
このとき、FeatureにはNewsRepositoryのインターフェースだけ公開し、実装の詳細は隠すことができるのが理想です。
モジュールと依存の方向を示した図がこちらです。
FeatureモジュールはNewsRepositoryモジュールに依存しますが、NewsRepositoryLiveモジュールには依存させません。
NewsRepositoryLiveにはWeb APIにリクエストするためのライブラリや、独自のエクステンションを含んでいる可能性がありますが、Featureモジュールはそれらを取り込む必要がなくなり実装をシンプルに保つことができます。
これは簡単に実現できます。
まず、NewsRepositoryモジュールでNewsRepositoryのインターフェースとなるstructを実装します。
そして、このモジュール内でNewsRepositoryをTestDependencyKeyに準拠させます。
// NewsRepositoryモジュール public struct NewsRepository { public var fetchNews: (_ id: Int) async throws -> News public init( fetchNews: @escaping (Int) async throws -> News ) { self.fetchNews = fetchNews } } extension NewsRepository: TestDependencyKey { public static let testValue: NewsRepository = .init( fetchNews: unimplemented("\(Self.self).fetchNews") ) public static let previewValue: NewsRepository = .init( fetchNews: unimplemented("\(Self.self).fetchNews") ) } public extension DependencyValues { var newsRepository: NewsRepository { get { self[NewsRepository.self] } set { self[NewsRepository.self] = newValue } } }
次に、NewsRepositoryLiveモジュールでNewsRepositoryをDependencyKeyに準拠させます。
ここではliveValue
のみ実装します。
// NewsRepositoryLiveモジュール extension NewsRepository: DependencyKey { public static let liveValue: NewsRepository = .init( fetchNews: { id in // Web APIにリクエストするコード } ) }
このように実装することで、上図の依存関係を実装することができます。
このプラクティスはTCAを使ったアプリのリファレンス実装であるisowordsでも取り入れられています。
例えば、Web APIのクライアント実装であるApiClientは、インターフェースを定義したApiClientモジュールと、ライブ実装を持つApiClientLiveモジュールに分かれています。
そして、各FeatureはApiClientモジュールのみに依存するようになっています。
ちなみに、この実装のやり方はDependencyKeyのドキュメンテーションコメントにも記載があります。
/// If you plan on separating your interface from your live implementation, conform to /// ``TestDependencyKey`` in your interface module, and conform to `DependencyKey` in your /// implementation module. public protocol DependencyKey: TestDependencyKey {
まとめ
新しくなったTCAのDIについてまとめてみました。
以前からTCAをプロジェクトに採用している人であれば、今回のアップデートで大幅に開発者体験が向上したことがわかるのではないでしょうか?
また、TCAはドキュメントについても以前よりかなり充実させてきています。
例えばstructを使った依存の設計方法について、今まではPoint-Freeの動画で取り上げたりisowordsで採用はされていましたが、TCAのドキュメントでこれについて言及はされていなかったと記憶しています。
これまではTCAの利用者がそれぞれの解釈でアーキテクチャの細かい所の設計をしてきましたが(例えばFeatureの構造や依存の設計)、ReducerProtocolになって実装の仕方で迷うことが減りました。
このメリットは非常に大きいと思います。
NewsPicksではこれからReducerProtocolへの移行を順次進めていきます。
新しいTCAを一緒に学んで行きたい方、お待ちしています!
*1:公式DocのTestingの1行目に記載があります