テストの後片付けでLiveData.removeObserver()すると怒られることがある
はじめに
そんなことをやる必要はありません。あくまでもネタとしてお読みください。
今回の:
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3 org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.3 androidx.lifecycle:lifecycle-livedata-ktx:2.2.0
顛末
ViewModel
のテストで、LiveData
やFlow
の監視を開始したり終了したり流れてきた値をチェックする処理を簡単にできるようにしたいと考えて、そのようなクラスを作り現行の処理と置き換えたところ、次のようにコルーチンの処理が全部終わってないといって怒られた。
kotlinx.coroutines.test.UncompletedCoroutinesError: Unfinished coroutines during teardown. Ensure all coroutines are completed or cancelled by your test. at kotlinx.coroutines.test.TestCoroutineDispatcher.cleanupTestCoroutines(TestCoroutineDispatcher.kt:178) ...
試しにコルーチンを起動させている個所をコメントアウトするなどしてみたが状況は変わらず、特に変わったことしてないはずなのにおかしいなーなどと思いながらコードを見直したところ、良かれと思って後片付けの箇所に足した処理があったのを思い出した。
override fun finished(description: Description?) { // TestWatcher の関数 super.finished(description) observers.forEach { (l, o -> l.removeObserver(o) } // これ scope.cancel() }
該当の処理をコメントアウトしたところ、怒られが解消した。
解説
androidx.lifecycle.liveData()
やandroidx.lifecycle.asLiveData()
で作ったLiveData
*1 は、activeになった時に内部のコルーチンスコープを使ってFlow.collect()
を開始する。その後、inactiveな状態になると一定時間(デフォルトでは5秒)待ったあとでFlow.collect()
のJob
をキャンセルするようになっている。この一定時間待つという処理にdelay()
が使われている。ということは当然コルーチンが起動しているので、これが完了するのを待たなければならない。テスト冒頭でDispatchers.setMain()
をしつつ、kotlinx.coroutines.test.runBlockingTest()
やそれに相当する関数を使うなどして、例えば、
runBlockingTest { observers.forEach { (l, o -> l.removeObserver(o) } }
としてやることでdelay()
を含んだJob
を消化できる。
ちなみに
androidx.lifecycle.asFlow()
の中ではJob
のキャンセル時にGlobalScope
なコルーチンが起動するのでこれも終わるまで待てないとだめ(一敗)。
*1:asLiveData()は内部でliveData()を呼んでいる
fluxとかMVIみたいな構造のアプリを作ってみたかった その5
前回からの進捗としては、NavigationDelegate
をActivity
, Fragment
に持たせることにしたり機能追加をやっていた。その中でViewModel
以下のクラスをリファクタリングしたところしっくりくるような形になったのでまとめてみる。
クラスの構成について
Androidのデータバインディングのスタイルをなるべく守りたいので、ViewModel
をDataBinding
に渡してデータのプロパティやイベントリスナの関数とバインドしたいという大前提がある。また、Repository
のインタフェースはFlow
を返すかsuspend
関数にして値を返すことにしているので、Flow
からLiveData
に変換する必要がある *1 。そのためにはCoroutineContext
が必要なので、ViewModel.viewModelScope.coroutineContext
を使うことにする。そのような前提と、これまでの実装を踏まえつつクラスごとの役割分担を見直した結果、次のような構成になった。
作ってる物の解像度が上がって作りやすくなった気がするけどこうやって見るとなんかごちゃごちゃしてんな pic.twitter.com/FjNs9kdmhO
— まつだあきひと (@akihito104) 2021年3月2日
この構成の基本的なアイディアは、ViewModel
をイベントリスナの部分と状態遷移の部分とに分割して、それぞれの処理を別のクラスに委譲するということ。ViewModel
の主な役割は状態のFlow
をLiveData
に変換するのみになる。また、ViewModel
はviewModelScope
を持っていたり、LiveData
を持たせる都合上、Androidの世界とビジネスモデルとの境界に立って両者の橋渡しを行う役割も暗に持つことになった。イベントリスナや状態を表すためのデータはViewModel
側が別途定義する。
イベントリスナはActions
クラスに委譲する。当初、このクラスにはMVIのIntent
のような役割を持たせたいと考えて、イベントバスに流れてくるイベントを捕まえて個別のFlow
を作ることだけをやっていたのだが、いまいち役割が軽く見えていて不要かもしれないと思い始めていた。しかし、今回の見直しで、Actions
にイベントリスナを実装させることでUIイベントの生成から個別のFlow
に流すまでを担当することになり、よりMVIのIntent
に近づいたように思う。また、こうすることでイベントを流す具体的な処理や具体的なイベントクラスを隠蔽することができるようになった。
状態遷移はViewModelSource
クラスに委譲する。このクラスはもともとViewStates
という名前で、View
の状態遷移を担当していたのでやること自体はあまり変わらない。ただ、このクラスにActions
を注入する都合から、このクラスにもイベントリスナを実装させActions
に委譲する形にしておけば、ViewModel
はこのクラスだけに依存すればよいことになる。もしそうなったとき、ViewStates
とViewModel
との違いは状態をFlow
で流すかLiveData
で持つかだけになるので、実質的にViewStates
はほぼViewModel
と同じものになるんだなと考えて名前をViewModel
の方に寄せる(ViewModelSource
)ことにした。
状態遷移の処理もこれを機に見直した。当初はView
のプロパティをそれぞれ個別のFlow
やLiveData
にわけて更新していたが、複数のFlow
やLiveData
が関係しあうとコードが複雑になってよくなかったので、ViewModel
から提供される状態データのインタフェースを実装したデータクラスを使って一元的に管理することにした。このあたりのことはまた別の記事に書こうと思うが、ざっくりいうとRxやFlow
にあるscan()
のような仕組みを使って、イベントが流れてくるごとに新しい状態のデータを生成して流すというような感じの実装になっている。
流れてきたイベントや状態遷移の結果、画面遷移したりSnackbar
で表示するようなちょっとしたメッセージフィードバックをしたいときはそれ用のFlow
を用意してActivity
やFramgent
向けにイベントを流す。
ここまでやってきて
当初はfluxやMVIのようなものを目指して作ってきたが、自分の理解力が足りなかったり実装力が無かったりしてバグが多く、ちょっと足したり削ったり移したりするとすぐ壊れてしまって難しかった。しかし、今の形に至り、ある程度わかりやすくまとまったのではないかと感じている。今、これは何かと尋ねられたら、大きくなってしまったViewModel
を分割するための一つの方法、と答えると思う。今の実装をもっと洗練させていくとfluxやMVIといったものに近づいていくのかもしれないが、もうあまり意識していない。ただ、ある程度の達成感は得られたものの、この試みがここで終わるのかと聞かれるとそれも違う気がしていて、同じタイトルでこのまま続けていくかもしれない。
*1:いずれFlowのままDataBindingに渡せるようになるだろうから必要なくなるかも
テスト対象を初期化する際にはlazyを使う
前置き
JavaでJUnit4のテストを書いていたころは、@Before
を付けた(setup()
のような名前の)メソッドの中にテスト対象やその依存関係の初期化処理を書き、テストケースの中で使うもの(テスト対象など)はフィールドに定義してsetup()
メソッドの中でフィールドにセットしていた。
private Foo sut = null; @Before public void setup() { final FooChild child = new FooChild(); sut = new Foo(child); }
個人的には、このテストクラスの文脈として共有したいことをsetup()
に書いて整理するというスタイルが気に入っていたのだが、Kotlinでテストを書くようになってからはそれをやめて、テスト対象やその依存関係をテストクラスのプロパティとして書くようになった。
private val sut: Foo = Foo( FooChild() )
多くの場合はこれで何の問題もないと思うが、気を付けておきたいのはTestRule
を使ってテストのセットアップを行う場合で、特にAndroidアプリの開発においては、テスト対象がLiveData
のプロパティを持っているようなときに使うInstantTaskExecutorRule
や、Kotlin coroutineのテストをするときにTestCoroutineDispatcher
を使うようなときである。
初期化の順序に気を付けよう
ポイントは初期化処理の実行順で、テストではざっくり言って次のようになる。
- (テストクラスの
@BeforeClass
関数) - テストクラスのコンストラクタ(プロパティの初期化処理、
TestRule
のコンストラクタの処理もここで行われる) TestRule.apply()
でStatement.evaluate()
より前に実行される処理(TestWatcher
を実装しているならstarting()
)InstantTaskExecutorRule
ではここでLiveData
内部で使うExecutor
をテスト用のモックに差し替えているTestCoroutineDispatcher
を使ったセットアップではこのタイミングでDispacher
をテスト用のモックに差し替える
- テストクラスの
@Before
関数 - テストケース
先の例ではテスト対象の初期化はリストの2番目で行われるが、テスト対象の初期化の中でLiveData
がアクティブになったり、coroutineのDispacher.Main
などにアクセスするようなことをやっているとその時点でモックしなさい例外*1が出てクラッシュする(androidx.lifecycle.liveData()
など)。なので、テスト対象の初期化はリストの3番目の後に行われるようにしなければならないが、さりとてsetup()
の中で生成してlateinit var
なプロパティにセットするというのも忍びない。手っ取り早く解決するにはlazy
を使うのがいいだろうか。
private val sut: Foo by lazy { val child = FooChild() Foo(child) }
こうしておけばsetup()
やテストケースの中で初めてsut
にアクセスしたときに初期化されるので、モックしなさい例外を回避できる。ただし、lateinit var
の方がすっきり書けるという場合にはそっちの方がいいと思います。テストクラス用のTestRule
を自前で用意してその中でテスト対象を初期化するようなケースでは、lateinit var
の方をバッキングフィールドにして隠すという手も使えるでしょうし。
*1:個人的にそう呼んでいる