Paging の実装をテストする

アプリに Paging ライブラリを実装する際は、堅牢なテスト戦略と組み合わせる必要があります。PagingSourceRemoteMediator などのデータ読み込みコンポーネントをテストして、期待どおりに動作することを確認してください。また、エンドツーエンドのテストを作成して、Paging の実装のコンポーネントがすべて、予期しない副作用なしで正しく連携することも確認してください。

このガイドでは、アプリのさまざまな アーキテクチャ レイヤで Paging ライブラリをテストする方法と、Paging 実装全体のエンドツーエンドのテストを作成する方法について説明します。

UI レイヤのテスト

Compose は collectAsLazyPagingItems を介して Paging データを宣言的に使用するため、UI レイヤのテストでは、ViewModel によって出力される Flow<PagingData<Value>> に完全に焦点を当てることができます。UI のデータが想定どおりであることを確認するテストを作成するには、paging-testing 依存関係を含めます。これには、asSnapshot 拡張機能が Flow<PagingData<Value>> に含まれています。ラムダ レシーバーに、スクロール操作のモックを可能にする API が用意されています。モックされたスクロール操作によって生成された標準の List<Value> を返します。これにより、ページングされるデータに、その操作によって生成された想定される要素が含まれていることをアサートできます。これを次のスニペットに示します。

fun test_items_contain_one_to_ten() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll to the 50th item in the list. This will also suspend till
    // the prefetch requirement is met if there's one.
    // It also suspends until all loading is complete.
    scrollTo(index = 50)
  }

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected values
  assertEquals(
    expected = (0..50).map(Int::toString),
    actual = itemsSnapshot
  )
}

また、以下のスニペットに示すように、特定の述語が満たされるまでスクロールすることもできます。

fun test_footer_is_visible() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll till the footer is visible
    appendScrollWhile {  item: String -> item != "Footer" }
  }

変換をテストする

PagingData ストリームに適用する変換を対象とする単体テストも作成する必要があります。asPagingSourceFactory 拡張機能を使用します。この拡張機能は、次のデータ型で使用できます。

  • List<Value>
  • Flow<List<Value>>

使用する拡張機能は、テストする内容に応じて選択します。次の形式で入力してください。

  • List<Value>.asPagingSourceFactory(): データで map()insertSeparators() などの静的変換をテストする場合。
  • Flow<List<Value>>.asPagingSourceFactory(): バッキング データソースへの書き込みなど、データの更新がページング パイプラインにどのように影響するかをテストする場合。

これらの拡張機能を使用するには、次のパターンを使用します。

  • 必要に応じて適切な拡張機能を使用して、PagingSourceFactory を作成します。
  • Repositoryフェイクで、返された PagingSourceFactory を使用します。
  • その RepositoryViewModel に渡します。

その後、前のセクションで説明したように ViewModel をテストできます。 次の ViewModel について考えてみましょう。

class MyViewModel(
  myRepository: myRepository
) {
  val items = Pager(
    config: PagingConfig,
    initialKey = null,
    pagingSourceFactory = { myRepository.pagingSource() }
  )
  .flow
  .map { pagingData ->
    pagingData.insertSeparators<String, String> { before, _ ->
      when {
        // Add a dashed String separator if the prior item is a multiple of 10
        before.last() == '0' -> "---------"
        // Return null to avoid adding a separator between two items.
        else -> null
      }
  }
}

MyViewModel で変換をテストするには、次のスニペットに示すように、変換するデータを表す静的 List に委任する MyRepository の疑似インスタンスを与えます。

class FakeMyRepository() : MyRepository {
    private val items = (0..100).map(Any::toString)
    private val pagingSourceFactory = items.asPagingSourceFactory()

    // Expose as a function so a new PagingSource instance is
    // created each time it is called by the Pager
    fun pagingSource() = pagingSourceFactory()
}

その後、次のスニペットに示すように、セパレータ ロジックのテストを作成できます。

fun test_separators_are_added_every_10_items() = runTest {
  // Create your ViewModel
  val viewModel = MyViewModel(
    myRepository = FakeMyRepository()
  )
  // Get the Flow of PagingData from the ViewModel with the separator transformations applied
  val items: Flow<PagingData<String>> = viewModel.items
                  
  val snapshot: List<String> = items.asSnapshot()

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected separators.
}

データレイヤのテスト

データレイヤのコンポーネントの単体テストを作成して、データソースからのデータが適切に読み込まれることを確認します。依存関係の 疑似バージョンを用意して、テスト対象のコンポーネントが独立して正しく機能することを確認します。リポジトリ レイヤでテストする必要がある主なコンポーネントは、PagingSourceRemoteMediator です。

PagingSource のテスト

PagingSource 実装の単体テストでは、TestPager を使用して PagingSource インスタンスを設定し、そこからデータを読み込みます。

テスト用の PagingSource インスタンスを設定するには、架空のデータをコンストラクタに渡します。これにより、テストのデータを制御できます。 次の例では、RedditApi パラメータは、Retrofit サーバー リクエストとレスポンス クラスを定義するインターフェースです。 疑似バージョンはインターフェースを実装し、必要な関数をオーバーライドして、テストで疑似オブジェクトがどのように反応するかを設定する便利なメソッドを提供します。

フェイクの準備ができたら、依存関係を設定し、テストで PagingSource オブジェクトを初期化します。次の例は、テスト投稿のリストを使用して FakeRedditApi オブジェクトを初期化し、RedditPagingSource インスタンスをテストする方法を示しています。

class SubredditPagingSourceTest {
  private val mockPosts = listOf(
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT)
  )
  private val fakeApi = FakeRedditApi().apply {
    mockPosts.forEach { post -> addPost(post) }
  }

  @Test
  fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
    val pagingSource = RedditPagingSource(
      fakeApi,
      DEFAULT_SUBREDDIT
    )

    val pager = TestPager(CONFIG, pagingSource)

    val result = pager.refresh() as LoadResult.Page

    // Write assertions against the loaded data
    assertThat(result.data)
    .containsExactlyElementsIn(mockPosts)
    .inOrder()
  }
}

TestPager を使用すると、次のこともできます。

  • PagingSource からの連続読み込みをテストします。
    @Test
    fun test_consecutive_loads() = runTest {

      val page = with(pager) {
        refresh()
        append()
        append()
      } as LoadResult.Page

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • PagingSource でエラーのシナリオをテストします。
    @Test
    fun refresh_returnError() {
        val pagingSource = RedditPagingSource(
          fakeApi,
          DEFAULT_SUBREDDIT
        )
        // Configure your fake to return errors
        fakeApi.setReturnsError()
        val pager = TestPager(CONFIG, source)

        runTest {
            source.errorNextLoad = true
            val result = pager.refresh()
            assertTrue(result is LoadResult.Error)

            val page = pager.getLastLoadedPage()
            assertThat(page).isNull()
        }
    }

RemoteMediator のテスト

RemoteMediator 単体テストの目的は、load() 関数が正しい MediatorResultを返すことを確認することです。データベースに挿入されるデータなどの副作用のテストは、統合テストに適しています。

最初のステップは、RemoteMediator 実装に必要な依存関係を判断することです。次の例は、Room データベース、Retrofit インターフェース、検索文字列を必要とする RemoteMediator 実装を示しています。

@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
  private val db: RedditDb,
  private val redditApi: RedditApi,
  private val subredditName: String
) : RemoteMediator&lt;Int, RedditPost&gt;() {
  ...
}

PagingSource のテスト セクションに示すように、Retrofit インターフェースと検索文字列を指定できます。 PagingSource のテスト セクションに示すように、Retrofit インターフェースと検索文字列を指定できます。Room データベースのモック バージョンを提供することは非常に複雑です。そのため完全なモック バージョンではなく、データベースのインメモリ実装を提供する方が簡単です。Room データベースを作成するには Context オブジェクトが必要なため、この RemoteMediator テストを androidTest ディレクトリに配置し、AndroidJUnit4 テストランナーで実行して、テストアプリのコンテキストにアクセスできるようにする必要があります。インストルメント化テストの詳細については、インストルメント化単体テストを作成するをご覧ください。

状態がテスト関数間でリークしないようにティアダウン関数を定義します。これにより、テスト実行間で一貫した結果が得られます。

@ExperimentalPagingApi
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class PageKeyedRemoteMediatorTest {
  private val postFactory = PostFactory()
  private val mockPosts = listOf(
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT)
  )
  private val mockApi = mockRedditApi()

  private val mockDb = RedditDb.create(
    ApplicationProvider.getApplicationContext(),
    useInMemory = true
  )

  @After
  fun tearDown() {
    mockDb.clearAllTables()
    // Clear out failure message to default to the successful response.
    mockApi.failureMsg = null
    // Clear out posts after each test run.
    mockApi.clearPosts()
  }
}

次に、load() 関数をテストします。この例では、次の 3 つのケースをテストします。

  • 第 1 のケースは、mockApi が有効なデータを返す場合です。load() 関数は MediatorResult.Success を返し、endOfPaginationReached プロパティは false である必要があります。
  • 第 2 のケースは、mockApi が正常なレスポンスを返しても、返されたデータが空である場合です。load() 関数は MediatorResult.Success を返し、endOfPaginationReached プロパティは true である必要があります。
  • 第 3 のケースは、データの取得時に mockApi が例外をスローする場合です。load() 関数は MediatorResult.Error を返す必要があります。

第 1 のケースをテストする手順は次のとおりです。

  1. 返すポストデータで mockApi を設定します。
  2. RemoteMediator オブジェクトを初期化します。
  3. load() 関数をテストします。
@Test
fun refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() = runTest {
  // Add mock results for the API to return.
  mockPosts.forEach { post -> mockApi.addPost(post) }
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertFalse { (result as MediatorResult.Success).endOfPaginationReached }
}

第 2 のテストでは、mockApi が空の結果を返す必要があります。各テストの実行後に mockApi からデータを消去するため、デフォルトでは空の結果が返されます。

@Test
fun refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() = runTest {
  // To test endOfPaginationReached, don't set up the mockApi to return post
  // data here.
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertTrue { (result as MediatorResult.Success).endOfPaginationReached }
}

最後のテストでは、load() 関数が MediatorResult.Error を正しく返すことをテストで確認できるように、mockApi が例外をスローする必要があります。

@Test
fun refreshLoadReturnsErrorResultWhenErrorOccurs() = runTest {
  // Set up failure message to throw exception from the mock API.
  mockApi.failureMsg = "Throw test failure"
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue {result is MediatorResult.Error }
}

エンドツーエンド テスト

単体テストでは、個々の Paging コンポーネントが独立して動作することが保証されますが、エンドツーエンド テストでは、アプリ全体が動作する確実性が増します。これらのテストは、データレイヤ(PagingSource または RemoteMediator)、ViewModel、Compose UI が予期しない副作用なしでシームレスに統合されることを確認するのに役立ちます。テストはまだモックの依存関係を必要としますが、通常はアプリコードの大部分をカバーします。

このセクションの例では、テストでネットワークを使用しないように、モック API の依存関係を使用します。モック API は、一貫性のあるテストデータのセットを返すように設定されているため、繰り返し可能なテストになります。エンドツーエンド テストでは、通常、実際のネットワーク API をフェイクに置き換えますが、テストの忠実性を維持するために、Paging ライブラリに実際のフェッチとローカル データベース キャッシュ(RemoteMediator を使用している場合)を処理させます。

依存関係のモック バージョンに簡単に切り替えられるようにコードを記述します。次の例では、基本的なサービス ロケータ実装を使用し、モック API を使用してテストを設定し、Compose 画面がページングされたデータを適切に消費して表示することを確認します。大規模なアプリでは、 Hilt のような依存関係挿入ライブラリを使用することで、より複雑な 依存関係グラフを管理できます。

テスト構造を設定したら、次に、Pager 実装によって返されたデータが正しいことを確認します。一方のテストでは、画面が最初に読み込まれるときに Compose UI に正しいアイテムが入力されることを確認し、他方のテストでは、ユーザー インタラクションに基づいて UI が追加のデータを正しく読み込むことを確認します。

次の例では、UI が想定されるページングされたデータを表示することをテストで確認します。

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RedditScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    private val postFactory = PostFactory()
    private val mockApi = MockRedditApi()

    @Before
    fun setup() {
        // Pre-populate the mock API with test data for the default subreddit
        mockApi.addPost(postFactory.createRedditPost(subreddit = "androiddev", title = "Jetpack Compose Paging"))

        // Swap your real dependency injection module/Service Locator with the mock API
        ServiceLocator.swap(
            object : DefaultServiceLocator(useInMemoryDb = true) {
                override fun getRedditApi(): RedditApi = mockApi
            }
        )
    }

    @Test
    fun loadsTheDefaultResults() = runTest {
        // 1. Set the Compose UI content
        composeTestRule.setContent {
            MyTheme {
                // Assume that this composable uses `collectAsLazyPagingItems()` internally
                RedditScreen(initialSubreddit = "androiddev")
            }
        }

        // 2. Wait for the asynchronous Paging loads to complete
        composeTestRule.waitUntilExactlyOneExists(
            matcher = hasText("Jetpack Compose Paging"),
            timeoutMillis = 5000
        )

        // 3. Assert that the loaded paged items are displayed correctly on screen
        composeTestRule.onNodeWithText("Jetpack Compose Paging").assertIsDisplayed()
    }

    @Test
    fun loadsNewDataBasedOnUserInput() = runTest {
        // Add data for a different subreddit to the mock API
        mockApi.addPost(postFactory.createRedditPost(subreddit = "compose", title = "Compose Testing"))

        composeTestRule.setContent {
            MyTheme {
                RedditScreen(initialSubreddit = "androiddev")
            }
        }

        // Wait for the initial load to finish
        composeTestRule.waitUntilExactlyOneExists(hasText("Jetpack Compose Paging"))

        // Simulate user entering a new subreddit in a text field and clicking search
        composeTestRule.onNodeWithTag("SubredditInput").performTextClearance()
        composeTestRule.onNodeWithTag("SubredditInput").performTextInput("compose")
        composeTestRule.onNodeWithTag("SearchButton").performClick()

        // Wait for the new paged data to load
        composeTestRule.waitUntilExactlyOneExists(
            matcher = hasText("Compose Testing"),
            timeoutMillis = 5000
        )

        // Assert the old data is gone and the new data is displayed
        composeTestRule.onNodeWithText("Jetpack Compose Paging").assertDoesNotExist()
        composeTestRule.onNodeWithText("Compose Testing").assertIsDisplayed()
    }
}

Flow<PagingData> はデータを非同期で読み込むため、アサーションを行う前に、Paging ライブラリが最初の読み込みをフェッチして collectAsLazyPagingItems に出力する時間を確保する必要があります。これを行うには、前の例に示すように、composeTestRule.waitUntil または waitUntilExactlyOneExists を使用します。

データが読み込まれたら、onNodeWithText を使用して Compose セマンティック ツリーに対して直接アサートし、アイテムが LazyColumn に実際にレンダリングされていることを確認できます。

参考情報

コンテンツを表示する