Androidでもテーブル駆動テストが書きたい!!MVI風実装パターン【RaMvi】という提案
こんにちはsaikiです。
前置き
ComposeMultiPlatformでデスクトップ&Androidアプリを作りたいなと思い、設計を考えていたところたまたまfeedに流れてきたmviの記事を見て、ええやん…っとなったので自分が実装するならこうしたいというのをまとめました。
mviではあると思ってるのですがもしかしたら違うかもという気もしてきたのと自分の思想がかなり強く入っているためMVI風として、別途名前をつけています。
What is MVI?
MVIの解説は本筋ではないので私が参考にさせていただいた下記記事をご覧ください。
雑に雰囲気だけ伝えると、MVIはMVVMやMVPにさらに縛りを追加したアーキテクチャです。
As my friend Ritesh Gupta likes to say:
“MVI prevents us from misusing patterns like MVP or MVVM.”
つまり、AndroidのMVVMorMVPとMVIは両立することが可能です。
そういうとややこしいですが、比較的導入が容易く理解もしやすいということでもあります。
What is RaMvi?
今回考案したMVIの実装パターンです。
ランビと読もうかな。マークをつけるならサイですね。
MVIの思想に加えReducerとActionの実装を強制するため頭文字のRとAを持ってきました。
Why is RaMvi?
前述した通りRaMviはMVIの実装パターンです。そのためまずはMVIと同様のメリットがあります。
1.Better separation of concerns: With MVI, the Intent layer is responsible for user actions, the Model layer is responsible for state management, and the View layer is responsible for rendering the state. This separation makes it easier to reason about app behavior and to make changes to the app without affecting other parts of the architecture.
2.More predictable app behavior: With MVI, the state of the app is always represented by a single data object, which makes it easier to reason about and predict the behavior of the app. This predictability can lead to fewer bugs and a better user experience.
3.Improved testability: MVI makes it easier to write unit tests, as each layer can be tested independently. The predictable flow of data through the architecture makes it easier to write tests that cover all possible states and edge cases.
これらのメリットを享受しつつ、RaMviでは
- testabilityの強化
- debuggabilityの強化
- 難しくない
を実現できるように更に制約を追加しています。
MVIに加えてFlux・Reduxにかなり影響を受けております。
あと今改めてdroidkaigi2018のMVIの動画を見たんですが、構成はだいぶ近いです。
Details of RaMvi
とりあえずこちらがandroid/architectureのリポジトリをフォークさせていただき修正したものになります。
※tasksとそのunitTestのみ置き換えており他はノータッチです。
前提として、MVIもRaMviもプレゼンテーション層にのみ言及しておりドメイン層・データ層の設計には関与しません。
UseCaseを用意するなりしないなり、クリーンアーキテクチャにするなりSingle Source of Truthにするなり好きにすることが可能ですので本記事では触れません。
では本題。
MVIの図はこちら
引用元: https://medium.com/fueled-engineering/why-mvi-538ee07f0f32
そしてRaMviの図はこちら
若干向きが違うのでわかりづらいですが差分としてはIntentとStateの間にStateHolder・Action・Reducerという具体的なクラスを追加しているだけです。
ではまずTaskListのrefreshを例にRaMviの大まかな処理の流れを見て聞きましょう。
補足情報として、AndroidではViewModelがStateHolderを実装します。なので以降はStateHolder=ViewModelと考えてもらって大丈夫です。
1.ViewからIntentが発行される。
UserがListをRefreshしたいと思い、ViewのリフレッシュボタンをタップするとViewからIntentが発行されStateHolderに渡されます。
コードとしてはprocessIntentメソッドが呼ばれます
2.StateHolderでIntentの種類に応じた処理に分岐します。
When文で型によって処理を振り分けます。今回はRefreshなのでrefresh()が呼ばれます。
3.処理を行う
処理を行い(domainやrepositryを叩くなど)その結果を受け取ります。
Refreshの場合は結果がReposirotyを通してFlowで流れてくるので直接は受け取りません。ちょっと説明としてはややこしくてすみません
4.Actionを作成する
受け取った結果からActoinを作成します。
今回はtaskRepositoryから最新のtasksを受け取りTasksAction.UpdateTasksを作成しています。
5.ReducerにActionとStateを渡し新しいStateを作成する
MutableStateに生やしたreduceメソッドを通してReducerと先ほど結果から作成したActionを渡し、reduceメソッドを叩きます。
Recuderでは受け取ったAction毎に新しいStateを作成、返却します。
6.結果をStateに反映する
reducerから受け取った結果をそのままStateに反映します。(5と同じ行)
7.Stateを元にUIが更新される
StateをcollectAsStateWithLifecycle()で監視しているので、Stateの更新によってUIが更新されます。
登場するクラス
以下、登場するクラスたちの紹介です
大して複雑ではない上に全てRaMviBase.ktにまとまっているのでそちらもご覧ください。
View
概要
一般的な文脈でのView(ComposableView,AndroidView,Activity,Fragment)です。
責務
1.Intentの発行
Userからの操作を受け付けIntentを発行しStateHolderの公開するprocessIntent関数に渡します
2.Stateの監視とUIの更新
StateHolderの公開するStateをObserveしUIを構築します。
他に特殊なことは特にないです。
Intent
概要
AndroidのIntentとは無関係です。
その名の通りUserの意図を表現するクラスです。
sealed classで表現され、UserのUI操作によりUserが行いたいことを表現したIntentが発行されます。
interface Intent
実体
責務
1.Userの意図の表現
Userの意図とそれにより発生する処理に必要な情報を持ちます。
Action
概要
アプリが行い、Viewに反映する可能性のある動作を表現します。
※前述した記事に出てくるActionとは少し位置付けが違うのでご注意ください。
RaMviの場合はdomain/dataを叩いた結果をReducerに伝えるための入れ物になります。
ReduxのActionに近いです。
interface Action
実体
責務
1.アプリの動作を表現しReducerに伝える
Intentと一対一対応はしませんし、一致することもあればしないこともあります。
State
概要
Viewの状態を表現するclassです。
複数の状態があるのであればなsealed classを有効に利用すると良いです。
interface State
実体(ほぼ元の実装のままです。あとでSealedクラスに変えるなどしたい)
責務
1.Viewの状態の表現
StateHolderに保持・更新されViewにobserveされます。
StateHolder
概要
StateとprocessIntent関数をもつclassです。
AndroidであればJetpackのViewModelを継承したクラスがこのInterfaceを実装します。
そうすることでいつものViewModelにInterfaceを強制した形になります。
interface StateHolders<I : Intent, S : State, A : Action> {
val state: StateFlow<S>
fun processIntent(intent: I) {
Logger.dIntentStart(intent)
processIntentInternal(intent)
Logger.dIntentEnd()
}
fun StateHolders<I, S, A>.processIntentInternal(intent: I)
fun MutableStateFlow<S>.reduce(reducer: Reducer<A, S>, action: A) {
Logger.dReduceStart(reducer, action, this.value)
this.value = reducer.reduce(action, this.value)
Logger.dReduceEnd(this.value)
}
}
実体
InterfeceとしてprocessIntentとstateを持ち、各ViewModelではprocessIntentInternalを実装します。
また、ログと利便性のためにMutableStateFlowの拡張メソッドとしてReducerを通してStateを更新するreduce関数を定義しています。
processInternalとreduceは拡張関数を利用することで外から呼べなくしていますがInterfaceの使い方としては美しくない気がしており他におしゃれな方法があれば書き換えたい所存です。アドバイスお待ちしております。
責務
1.Actionの発行
ViewからIntentを受け取り、Intentの情報と持っている情報を使って必要な処理をし、その結果をもつActionを発行します
2.ReducerへのActionの受け渡し
ReducerにActionと現在のStateを渡し新しいStateを受け取ります。
3.Stateの更新
保持するStateにReducerから受け取ったStateを反映します。
Reducer
概要
ActionとStateを受け取り新しいStateを返すclassです。
Viewレベルのロジックの大半を担当します。
ReduxのReducerに影響を受けています。(登場する層が違いますが役割はほぼ同じです)
interface Reducer<A : Action, S : State> {
fun reduce(action: A, prevState: S): S
}
実体
責務
1.Stateの作成
受け取ったActionとStateから新しいStateを作成します。
全てのStateの更新ロジックがここに集約されます。
PayloadCreator(optional)
DomainやRepositoryを叩く際にパラメータを準備するケースがあると思います。その処理をStateHolderに書くのではなくここに書こうぜ、というクラスになります。
本当はInterface化できると良かったんですが、ちょっとしっくりこないので一旦任意にしています。
あるとテストが書きやすいと思うのですが多分対外大したロジックもない気がするのでやりすぎかなあというのとPayload作るためのParameterをsealedClassにしてもなんか何表現してるのかよくわからんなあみたいになりました。
処理複雑だなあみたいな時に作ればいいかも?実際今回のTODOListの書き換えでは登場の機会がなかったです。(パラメータを作成するタイミングがなかった)
メリットの再確認
testable
ここが本当に良いと思います。
前述した通り、RaMviでは全てのStateの更新処理がReducerに集約します。
まずこれにより、ViewModelから大半の処理を引き剥がすことが可能です。
そして、Reducerのもつただ一つの関数reduceは純粋関数(Inが同じならOutも同じ)です。
つまり、テーブル駆動テストが可能です…!!!
これが個人的に本当に良い。
私はかれこれ4年間TDDしておりViewModelのテストも散々書いていたのですが、ViewModelのテストは書くのまじ辛いです。多分書いたことある人はみんなそう思ってると思います。(ソースはないです)
詳しくはそのうち他で書こうかなという気になってきましたが、initで色々したり副作用があったり山ほどMock用意しないといけないしで考えることが多くとにかく大変でmockライブラリの知識やテストのためだけの知識が必要になってきます。
コスパを考えるとViewModelのテストをどう書くか考えるより、いかにViewModelにテストを書かなくて済むかを考えるべき、というマインドでRaMviは作られています。
実を言うとここがメインです。
実際に今回書いたTestがこちらです。追加のライブラリなくJunit4のみで書いています。
actとassertのみの2行のテストにあとはひたすらTestDataを定義していくだけの簡単な作業で元々ViewModelにあったほぼ全てのロジックをテストすることができています。
もちろん余裕のカバレッジ100%です。
全てのReducerが同様の形式になるため、コピペして対象クラスとデータを変更するだけで誰でも価値のあるテストを書くことが可能です。
さらに言うと書き方が一定なのでgithubCopilotがかなりいい仕事をしてくれてtab押してるだけで済むこともあります。気持ち良すぎる。
debuggable
Userの全ての操作がIntentで表現されStateHolderを通ります。
そして、ログ出力をデフォルト実装に含めているため、常に全ての操作がログに出力されます。
同様にStateの全ての更新がReducerを通り、ログが出力されます。
これにより、ログを見ながらアプリをぽちぽち操作しているだけでどのクラスで何が行われているのかを容易に追うことが可能です。
予期せぬバグが発生した時も、操作をログから辿ることができるので、原因のあたりをつけるのが非常に楽になります。
実際今回拝借したTODOListアプリにて、
- 一つcompleteしているタスクがある状態でリフレッシュすると一瞬emptyの表示がされる
- completeしたタスクを再度activeにしても取り消し線が消えない
という挙動があったのですが、いずれもログでStateの状態を追うことで
- refreshしたあと空のリストがRepositoryから流れてきている
- stateのフラグは更新されているがViewへの反映がうまくされていない
ことがわかり、それぞれData層とViewの問題であることを特に苦労なく見つけることができました。(もちろんその先の調査はまた必要…)
新しいプロジェクトに入ったりすると画面とクラス名が全く紐づいてない状態から調査することになるのできついんですが、このような形で全画面でログが出力されていればその画面さえ開ければぽちぽちすればクラスと処理がつかめるのでかなり楽なんじゃないかなと思っています。
実際にログの例
Filterを適用した時のログはこんな感じです。SelectFilterTypeのIntentが発行され、SetFilteringが実行されたことが一目でわかります。
RaMvi:: ================ Intent start ================
RaMvi:: intent name : SelectFilterType
RaMvi:: intent detail : SelectFilterType(requestType=ALL_TASKS)
RaMvi:: ---------------- Reduce start ----------------
RaMvi:: reducer name : TasksReducer
RaMvi:: action name : SetFiltering
RaMvi:: prevState : TasksUiState(items=Items(allItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation work required., isCompleted=false, id=PISA), Task(title=Finish bridge inTacoma, description=Found awesome girders at half the cost!, isCompleted=true, id=TACOMA)], filteredItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation workrequired., isCompleted=false, id=PISA)]), isLoading=false, filteringUiInfo=ActiveTasks, userMessage=null, editingTargetTask=null)
RaMvi:: action detail : SetFiltering(requestType=ALL_TASKS)
RaMvi:: newState : TasksUiState(items=Items(allItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation work required., isCompleted=false, id=PISA), Task(title=Finish bridge inTacoma, description=Found awesome girders at half the cost!, isCompleted=true, id=TACOMA)], filteredItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation workrequired., isCompleted=false, id=PISA), Task(title=Finish bridge in Tacoma, description=Found awesome girders at half thecost!, isCompleted=true, id=TACOMA)]), isLoading=false, filteringUiInfo=AllTasks, userMessage=null, editingTargetTask=null)
RaMvi:: ---------------- Reduce end ----------------
RaMvi:: ================ Intent end ================
また、Refreshの際は、直接結果を受け取らずflowで流れてくるデータによりState更新が走りますが、その際の挙動もログを見ることで比較的容易に理解できます。
RaMvi:: ================ Intent start ================
RaMvi:: intent name : Refresh
RaMvi:: intent detail : com.example.android.architecture.blueprints.todoapp.tasks.TasksContract$TasksIntent$Refresh@901257e
RaMvi:: ---------------- Reduce start ----------------
RaMvi:: reducer name : TasksReducer
RaMvi:: action name : StartLoading
RaMvi:: prevState : TasksUiState(items=Items(allItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation work required., isCompleted=false, id=PISA), Task(title=Finish bridge inTacoma, description=Found awesome girders at half the cost!, isCompleted=true, id=TACOMA)], filteredItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation workrequired., isCompleted=false, id=PISA)]), isLoading=false, filteringUiInfo=ActiveTasks, userMessage=null, editingTargetTask=null)
RaMvi:: action detail : com.example.android.architecture.blueprints.todoapp.tasks.TasksContract$TasksAction$StartLoading@c5d8adf
RaMvi:: newState : TasksUiState(items=Items(allItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation work required., isCompleted=false, id=PISA), Task(title=Finish bridge inTacoma, description=Found awesome girders at half the cost!, isCompleted=true, id=TACOMA)], filteredItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation workrequired., isCompleted=false, id=PISA)]), isLoading=true, filteringUiInfo=ActiveTasks, userMessage=null, editingTargetTask=null)
RaMvi:: ---------------- Reduce end ----------------
RaMvi:: ================ Intent end ================
RaMvi:: ---------------- Reduce start ----------------
RaMvi:: reducer name : TasksReducer
RaMvi:: action name : UpdateTasks
RaMvi:: prevState : TasksUiState(items=Items(allItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation work required., isCompleted=false, id=PISA), Task(title=Finish bridge inTacoma, description=Found awesome girders at half the cost!, isCompleted=true, id=TACOMA)], filteredItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation workrequired., isCompleted=false, id=PISA)]), isLoading=true, filteringUiInfo=ActiveTasks, userMessage=null, editingTargetTask=null)
RaMvi:: action detail : UpdateTasks(tasks=[])
RaMvi:: newState : TasksUiState(items=Items(allItems=[], filteredItems=[]), isLoading=false, filteringUiInfo=ActiveTasks, userMessage=null, editingTargetTask=null)
RaMvi:: ---------------- Reduce end ----------------
RaMvi:: ---------------- Reduce start ----------------
RaMvi:: reducer name : TasksReducer
RaMvi:: action name : UpdateTasks
RaMvi:: prevState : TasksUiState(items=Items(allItems=[], filteredItems=[]), isLoading=false, filteringUiInfo=ActiveTasks, userMessage=null, editingTargetTask=null)
RaMvi:: action detail : UpdateTasks(tasks=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation work required., isCompleted=false, id=PISA), Task(title=Finish bridge in Tacoma, description=Found awesomegirders at half the cost!, isCompleted=true, id=TACOMA)])
RaMvi:: newState : TasksUiState(items=Items(allItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation work required., isCompleted=false, id=PISA), Task(title=Finish bridge inTacoma, description=Found awesome girders at half the cost!, isCompleted=true, id=TACOMA)], filteredItems=[Task(title=Build tower in Pisa, description=Ground looks good, no foundation workrequired., isCompleted=false, id=PISA)]), isLoading=false, filteringUiInfo=ActiveTasks, userMessage=null, editingTargetTask=null)
RaMvi:: ---------------- Reduce end ----------------
Stateが大きくなってくるとログを見るのも大変になってくるのでログの出し方は改善の余地がありますね。
とはいえ全ての画面で統一したログを出力できるのは非常に有益だと思っています。
簡単
見ての通り、特に複雑なことはなくInterfaceを実装して順にメソッドを呼んでいくだけなので比較的簡単だと思います。
Interfaceも1ファイルにまとまる程度です。
さまざまなスキルレベルのエンジニアが参加する可能性のあるチーム開発において導入と理解が簡単なことはかなり重要だと個人的には思っています。
個人的に導入するのに外部のライブラリやフレームワークを入れるのには抵抗がある(何か問題が発生した時に中まで追うのが大変だったり更新が切れたりするリスクがある)ので1ファイルのコピペである程度の実装の強制と全画面での統一的なログ出力が済むのは結構大きいメリットに感じています。
終わりに
ということでMVIの実装パターン【RaMvi】の紹介でした。
ツッコミどこもあるかと思いますのでご意見ご感想お待ちしております。
まだ検討してサンプルを作成しただけで実際のプロジェクトへの導入・運用はしていないのでとりあえず自分の趣味プロジェクトに入れて様子を見て随時アップデートしていければと思います。
もうすぐdoroidKaigiですがそこでもMVIに関する発表があるようなので楽しみです。
多分観てからまたこの記事をアップデートする気がします。(そう思ったので大急ぎで書いた)
前々から思っていましたがこういうのわかりやすく説明するの大変ですね。実装する方が楽。
こぼれ話としてStateはSealed classでいい感じに表現したい、画面遷移などのEventは同処理する?StateはStateFlow?mutableStateOf?とか色々あるのでそれはそれでそのうち書ければ書きたい所存です。
以上、長い記事を読んでいただきありがとうございました。
ではまた。
Discussion