Flow を使用するユニットやモジュールのテスト方法は、テスト対象が Flow を入力として使用するか、出力として使用するかによって異なります。
- テスト対象から Flow を監視する場合は、制御可能な疑似依存関係内で Flow を生成するという方法があります。
- ユニットやモジュールで Flow を公開している場合は、テストで Flow から出力されるアイテムの読み取りや確認を行えます。
疑似プロデューサの作成
テスト対象が Flow のコンシューマである場合、よく使われるのはプロデューサを疑似実装に置き換えるという方法です。たとえば、本番環境の 2 つのデータソースからデータを取得しているリポジトリを監視するクラスがあるとします。
リポジトリとその依存関係を、常に同じ疑似データを出力する疑似リポジトリに置き換えることで、テストの条件を固定化できます。
事前に定義した一連の値を Flow に出力するには、次のように flow
ビルダーを使用します。
class MyFakeRepository : MyRepository {
fun observeCount() = flow {
emit(ITEM_1)
}
}
テストでは、次のようにして疑似リポジトリを挿入し、実際の実装を置き換えます。
@Test
fun myTest() {
// Given a class with fake dependencies:
val sut = MyUnitUnderTest(MyFakeRepository())
// Trigger and verify
...
}
これで、テスト対象への出力を制御できるようになったため、そこからの出力をチェックすることで正しく機能しているかを確認できます。
テストにおける Flow 出力のアサーション
テスト対象で Flow を公開している場合、テストではデータ ストリームの要素についてアサーションを行う必要があります。
上記の例のリポジトリで Flow を公開しているとします。
特定のテストでは、Flow から最初に出力されるアイテムまたは有限個のアイテムのみをチェックするだけで済みます。
first()
を呼び出すことで、Flow への最初の出力を使用できます。この関数は、最初のアイテムを受け取るまで待機し、その後キャンセル信号をプロデューサに送信します。
@Test
fun myRepositoryTest() = runTest {
// Given a repository that combines values from two data sources:
val repository = MyRepository(fakeSource1, fakeSource2)
// When the repository emits a value
val firstItem = repository.counter.first() // Returns the first item in the flow
// Then check it's the expected item
assertEquals(ITEM_1, firstItem)
}
テストで複数の値をチェックする必要がある場合は、toList()
を呼び出します。これにより、Flow はソースからすべての値が出力されるのを待ってから、一連の値をリストとして返します。これは、有限個のデータ ストリームに対してのみ有効です。
@Test
fun myRepositoryTest() = runTest {
// Given a repository with a fake data source that emits ALL_MESSAGES
val messages = repository.observeChatMessages().toList()
// When all messages are emitted then they should be ALL_MESSAGES
assertEquals(ALL_MESSAGES, messages)
}
より複雑なアイテムのコレクションを必要とするデータ ストリームや、有限個のアイテムを返さないデータ ストリームの場合は、Flow
API を使用することでアイテムの選択および変換を行えます。次に例を示します。
// Take the second item
outputFlow.drop(1).first()
// Take the first 5 items
outputFlow.take(5).toList()
// Takes the first item verifying that the flow is closed after that
outputFlow.single()
// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)
テスト中に継続的に収集する
上記の例のように toList()
を使用して Flow を収集すると、collect()
が内部で使用され、結果リスト全体を返す準備ができるまで停止されます。
Flow に値を出力させるアクションと、出力された値に対するアサーションをインターリーブするために、テスト中に Flow から値を継続的に収集できます。
たとえば、テスト対象の次の Repository
クラスと、テスト中に値を動的に生成する emit
メソッドを含む付属の疑似データソース実装を取り上げます。
class Repository(private val dataSource: DataSource) {
fun scores(): Flow<Int> {
return dataSource.counts().map { it * 10 }
}
}
class FakeDataSource : DataSource {
private val flow = MutableSharedFlow<Int>()
suspend fun emit(value: Int) = flow.emit(value)
override fun counts(): Flow<Int> = flow
}
テストでこの疑似を使用する場合には、Repository
から値を継続的に受け取る収集コルーチンを作成できます。この例では、値をリストに収集し、その内容に対してアサーションを実行します。
@Test
fun continuouslyCollect() = runTest {
val dataSource = FakeDataSource()
val repository = Repository(dataSource)
val values = mutableListOf<Int>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
repository.scores().toList(values)
}
dataSource.emit(1)
assertEquals(10, values[0]) // Assert on the list contents
dataSource.emit(2)
dataSource.emit(3)
assertEquals(30, values[2])
assertEquals(3, values.size) // Assert the number of items collected
}
Repository
によって公開されるフローは完了しないため、収集している toList
呼び出しは返されません。TestScope.backgroundScope
で収集コルーチンを開始すると、テストが終了する前にコルーチンがキャンセルされるようにする必要があります。そうしないと、runTest
がその完了を待機し続け、テストの応答が停止して、最終的に失敗します。
ここで、収集コルーチンに UnconfinedTestDispatcher
がどのように使用されているかについて注目してください。これにより、収集コルーチンが積極的に開始され、launch
が返された後に値を受け取る準備が整います。
タービンの使用
サードパーティの Turbine ライブラリには、収集コルーチンを作成するための便利な API と、Flow をテストするためのその他の便利な機能が用意されています。
@Test
fun usingTurbine() = runTest {
val dataSource = FakeDataSource()
val repository = Repository(dataSource)
repository.scores().test {
// Make calls that will trigger value changes only within test{}
dataSource.emit(1)
assertEquals(10, awaitItem())
dataSource.emit(2)
awaitItem() // Ignore items if needed, can also use skip(n)
dataSource.emit(3)
assertEquals(30, awaitItem())
}
}
詳細については、ライブラリのドキュメントをご覧ください。
StateFlow のテスト
StateFlow
は監視可能なデータホルダーです。これを収集することで、保持される値をストリームとして時間の経過とともに監視できます。この値のストリームは混同されています。つまり、StateFlow
に値がすぐに設定される場合、その StateFlow
のコレクタがすべての中間値を受け取ることが保証されず、最新の値のみを受け取ることになります。
テストでは、混同を念頭に置いておくと、Turbine などの他のフローと同様に StateFlow
の値を収集できます。テストシナリオによっては、すべての中間値を収集してアサートすることが望ましい場合もあります。
ただし、通常は StateFlow
をデータホルダーとして扱い、代わりにその value
プロパティでアサーションを行うことをおすすめします。このように、テストでは特定の時点でオブジェクトの現在の状態を検証し、混同の有無には依存しません。
たとえば、Repository
から値を収集し、StateFlow
の UI に公開する ViewModel
があるとします。
class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
private val _score = MutableStateFlow(0)
val score: StateFlow<Int> = _score.asStateFlow()
fun initialize() {
viewModelScope.launch {
myRepository.scores().collect { score ->
_score.value = score
}
}
}
}
この Repository
の疑似実装は次のようになります。
class FakeRepository : MyRepository {
private val flow = MutableSharedFlow<Int>()
suspend fun emit(value: Int) = flow.emit(value)
override fun scores(): Flow<Int> = flow
}
この疑似で ViewModel
をテストする場合、疑似から値を出力して ViewModel
の StateFlow
の更新をトリガーし、更新された value
をアサートできます。
@Test
fun testHotFakeRepository() = runTest {
val fakeRepository = FakeRepository()
val viewModel = MyViewModel(fakeRepository)
assertEquals(0, viewModel.score.value) // Assert on the initial value
// Start collecting values from the Repository
viewModel.initialize()
// Then we can send in values one by one, which the ViewModel will collect
fakeRepository.emit(1)
assertEquals(1, viewModel.score.value)
fakeRepository.emit(2)
fakeRepository.emit(3)
assertEquals(3, viewModel.score.value) // Assert on the latest value
}
stateIn によって作成された StateFlow の操作
前のセクションでは、ViewModel
は MutableStateFlow
を使用して、Repository
からの Flow によって出力された最新の値を格納します。これは一般的なパターンであり、通常はコールド Flow をホット StateFlow
に変換する stateIn
演算子を使用して簡単に実装されます。
class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
val score: StateFlow<Int> = myRepository.scores()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}
stateIn
演算子には SharingStarted
パラメータがあります。このパラメータが、アクティブになって基盤になる Flow の使用を開始するタイミングを決定します。ビューモデルでは、SharingStarted.Lazily
や SharingStarted.WhileSubscribed
などのオプションがよく使用されます。
テストで StateFlow
の value
をアサートしている場合でも、コレクタを作成する必要があります。空のコレクタにすることもできます。
@Test
fun testLazilySharingViewModel() = runTest {
val fakeRepository = HotFakeRepository()
val viewModel = MyViewModelWithStateIn(fakeRepository)
// Create an empty collector for the StateFlow
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.score.collect {}
}
assertEquals(0, viewModel.score.value) // Can assert initial value
// Trigger-assert like before
fakeRepository.emit(1)
assertEquals(1, viewModel.score.value)
fakeRepository.emit(2)
fakeRepository.emit(3)
assertEquals(3, viewModel.score.value)
}
参考情報
- Android での Kotlin コルーチンのテスト
- Android での Kotlin Flow
StateFlow
とSharedFlow
- Kotlin のコルーチンと Flow に関する参考情報