測試分頁實作

在應用程式中實作分頁程式庫時,應搭配完善的測試策略。建議您測試 PagingSourceRemoteMediator 等資料載入元件,確保元件運作正常。此外,您也應撰寫端對端測試,確認分頁實作中的所有元件都能正常運作,而不會產生意外的副作用。

本指南說明如何在應用程式的不同架構層測試 Paging 程式庫,以及如何為整個 Paging 實作項目撰寫端對端測試。

UI 層測試

由於 Compose 會透過 collectAsLazyPagingItems 以宣告方式使用 Paging 資料,因此 UI 層測試可以完全著重於 ViewModel 發出的 Flow<PagingData<Value>>。如要撰寫測試,驗證 UI 中的資料是否符合預期,請納入 paging-testing 依附元件。這個依附元件包含 asSnapshotFlow<PagingData<Value>> 擴充功能,其中的 lambda 接收器提供 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
  • 將該 Repository 傳遞至 ViewModel

接著,您就可以依據上一節的說明測試 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 中測試轉換作業,請提供 MyRepository 的假例項,此例項會委派至用來代表所要轉換資料的靜態 List,如以下程式碼片段所示:

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 實作項目的單元測試涉及設定 PagingSource 例項,以及使用 TestPager 載入該例項的資料。

如要設定用於測試的 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 介面和搜尋字串。提供 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() 函式。在這個範例中,有三個案例可以進行測試:

  • 第一個案例是 mockApi 傳回有效的資料。load() 函式應傳回 MediatorResult.Success,而 endOfPaginationReached 屬性應為 false
  • 第二個案例是 mockApi 傳回成功的回應,但傳回的資料是空的。load() 函式應傳回 MediatorResult.Success,而 endOfPaginationReached 屬性應為 true
  • 第三個案例是 mockApi 在擷取資料時擲回例外狀況。load() 函式應會傳回 MediatorResult.Error

請按照下列步驟測試第一個案例:

  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 }
}

如要進行第二個測試,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 }
}

如要進行最後一個測試,mockApi 必須擲回例外狀況,以便驗證 load() 函式是否正確傳回 MediatorResult.Error

@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 }
}

端對端測試

單元測試可保證個別分頁元件能夠獨立運作,但端對端測試更能確保應用程式整體都能正常運作。這些測試有助於驗證資料層 (PagingSourceRemoteMediator)、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.waitUntilwaitUntilExactlyOneExists,如上一個範例所示。

載入資料後,您可以使用 onNodeWithText 直接對 Compose 語意樹狀結構進行斷言,確認項目是否確實在 LazyColumn 中算繪。

其他資源

Views content