アプリに Paging ライブラリを実装する際は、堅牢なテスト戦略と組み合わせる必要があります。PagingSource や RemoteMediator などのデータ読み込みコンポーネントをテストして、期待どおりに動作することを確認してください。また、エンドツーエンドのテストを作成して、Paging の実装のコンポーネントがすべて、予期しない副作用なしで正しく連携することも確認してください。
このガイドでは、アプリのさまざまなアーキテクチャ レイヤで Paging ライブラリをテストする方法と、Paging 実装全体のエンドツーエンドのテストを作成する方法について説明します。
UI レイヤのテスト
Compose は collectAsLazyPagingItems を介して Paging データを宣言的に使用するため、UI レイヤのテストは主に ViewModel から出力される Flow<PagingData<Value>> に焦点を当てることができます。UI 内のデータが想定どおりであることを確認するためのテストの作成には、paging-testing の依存関係を含めます。Flow<PagingData<Value>> に asSnapshot 拡張機能が含まれています。ラムダ レシーバーに、スクロール操作のモックを可能にする 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 で変換をテストするには、次のスニペットに示すように、変換するデータを表す静的 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.
}
Compose の読み込み状態をテストする
Compose UI コンポーネントがさまざまな読み込み状態(LoadState.Loading や LoadState.Error など)にどのように反応するかを個別にテストするには、MutableStateFlow を介して PagingData.empty と PagingData.from を使用し、明示的な LoadStates を含む静的な PagingData スナップショットを出力します。
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun userListScreen_displaysLoadingDataAndErrorStates() {
val idle = LoadState.NotLoading(endOfPaginationReached = false)
// Emit static PagingData snapshots to test UI state transitions without a
// Pager or ViewModel.
val pagingDataFlow = MutableStateFlow(PagingData.empty<User>())
composeTestRule.setContent {
val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()
UserListScreen(lazyPagingItems = lazyPagingItems)
}
// Verify the initial loading UI before any items arrive.
pagingDataFlow.value = PagingData.empty(
sourceLoadStates = LoadStates(
refresh = LoadState.Loading,
prepend = idle,
append = idle
)
)
composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()
// Verify that loaded items replace the loading indicator.
pagingDataFlow.value = PagingData.from(
data = listOf(User(id = "1", label = "User A")),
sourceLoadStates = LoadStates(
refresh = idle,
prepend = idle,
append = idle
)
)
composeTestRule.onNodeWithTag("LoadingSpinner").assertIsNotDisplayed()
composeTestRule.onNodeWithText("User A").assertIsDisplayed()
// Verify that a refresh failure surfaces the error message in the UI.
pagingDataFlow.value = PagingData.empty(
sourceLoadStates = LoadStates(
refresh = LoadState.Error(IOException("Network unavailable")),
prepend = idle,
append = idle
)
)
composeTestRule.onNodeWithText("Network unavailable").assertIsDisplayed()
}
データレイヤのテスト
データレイヤのコンポーネントの単体テストを作成して、データソースからのデータが適切に読み込まれることを確認します。依存関係の疑似バージョンを用意して、テスト対象のコンポーネントが独立して正しく機能することを確認します。リポジトリ レイヤでテストする必要がある主なコンポーネントは、PagingSource と RemoteMediator です。
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<Int, RedditPost>() {
...
}
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 のケースをテストする手順は次のとおりです。
- 返すポストデータで
mockApiを設定します。 RemoteMediatorオブジェクトを初期化します。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<Int, RedditPost>(
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<Int, RedditPost>(
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<Int, RedditPost>(
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 を偽の API に置き換えますが、テストの忠実性を維持するために、Paging ライブラリに実際のフェッチとローカル データベース キャッシュ(RemoteMediator を使用している場合)を処理させます。
依存関係のモック バージョンを簡単にスワップできるようにコードを記述します。次の例では、基本的なサービス ロケータの実装を使用し、モック API を使用してテストを設定して、Compose 画面がページングされたデータを適切に消費して表示することを確認します。大規模なアプリでは、Hilt などの依存関係挿入ライブラリを使用すると、より複雑な依存関係グラフを管理できます。
テスト構造を設定したら、次に、Pager 実装によって返されたデータが正しいことを確認します。1 つのテストでは、画面が最初に読み込まれたときに Compose UI に正しいアイテムが入力されることを確認し、もう 1 つのテストでは、ユーザー操作に基づいて 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 でレンダリングされていることを確認できます。
参考情報
コンテンツを閲覧する
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- ネットワークとデータベースからページングする
- Paging 3 に移行する
- ページング データを読み込む、表示する