アプリに Paging ライブラリを実装する際は、堅牢なテスト戦略と組み合わせる必要があります。PagingSource や RemoteMediator などのデータ読み込みコンポーネントをテストして、期待どおりに動作することを確認してください。また、エンドツーエンドのテストを作成して、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を使用します。- その
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.
}
データレイヤのテスト
データレイヤのコンポーネントの単体テストを作成して、データソースからのデータが適切に読み込まれることを確認します。依存関係の
疑似バージョンを用意して、テスト対象のコンポーネントが独立して正しく機能することを確認します。リポジトリ レイヤでテストする必要がある主なコンポーネントは、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 インターフェースと検索文字列を指定できます。
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 をフェイクに置き換えますが、テストの忠実性を維持するために、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 に実際にレンダリングされていることを確認できます。
参考情報
コンテンツを表示する
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- ネットワークとデータベースからページングする
- Paging 3 に移行する
- ページング データを読み込む、表示する