Android での Kotlin Flow のテスト

コレクションでコンテンツを整理 必要に応じて、コンテンツの保存と分類を行います。

Flow を使用するユニットやモジュールのテスト方法は、テスト対象が Flow を入力として使用するか、出力として使用するかによって異なります。

  • テスト対象から Flow を監視する場合は、制御可能な疑似依存関係内で Flow を生成するという方法があります。
  • ユニットやモジュールで Flow を公開している場合は、テストで Flow から出力されるアイテムの読み取りや確認を行えます。

疑似プロデューサの作成

テスト対象が Flow のコンシューマである場合、よく使われるのはプロデューサを疑似実装に置き換えるという方法です。たとえば、本番環境の 2 つのデータソースからデータを取得しているリポジトリを監視するクラスがあるとします。

テスト対象とデータレイヤ
図 1. テスト対象とデータレイヤ。

リポジトリとその依存関係を、常に同じ疑似データを出力する疑似リポジトリに置き換えることで、テストの条件を固定化できます。

依存関係を疑似実装に置き換える
図 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 を公開している疑似依存関係を含むリポジトリ
図 3. 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>()
    val collectJob = launch(UnconfinedTestDispatcher()) {
        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

    collectJob.cancel()
}

Repository によって公開されるフローは完了しないため、収集している toList 呼び出しは返されません。したがって、収集コルーチンは、テストが終了する前に明示的にキャンセルする必要があります。そうしないと、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 の使用を開始するタイミングを決定します。ViewModel では、SharingStarted.LazilySharingStarted.WhileSubsribed などのオプションがよく使用されます。

テストで StateFlowvalue をアサートしている場合でも、コレクタを作成する必要があります。空のコレクタにすることもできます。

@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    val collectJob = launch(UnconfinedTestDispatcher()) { 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)

    // Cancel the collecting job at the end of the test
    collectJob.cancel()
}

参考情報