アプリに Paging ライブラリを実装する際は、堅牢なテスト戦略と組み合わせる必要があります。PagingSource
や RemoteMediator
などのデータ読み込みコンポーネントをテストして、期待どおりに動作することを確認してください。また、エンドツーエンドのテストを作成して、Paging の実装のコンポーネントがすべて、予期しない副作用なしで正しく連携することも確認してください。
このガイドでは、ページング データソースを単独でテストする方法、ページング データに対して実行する変換をテストする方法、Paging の実装全体にわたってエンドツーエンドのテストを作成する方法について説明します。このガイドの例は、ネットワークを使用したページングのサンプルに基づいています。
リポジトリ レイヤのテスト
リポジトリ レイヤのコンポーネントの単体テストを作成して、データソースからのデータが適切に読み込まれることを確認します。依存関係のモック バージョンを用意して、テスト対象のコンポーネントが独立して正しく機能することを確認します。リポジトリ レイヤでテストする必要がある主なコンポーネントは、PagingSource
と RemoteMediator
です。
PagingSource のテスト
PagingSource
クラスの単体テストでは、PagingSource
インスタンスをセットアップして、load()
関数が LoadParams
引数に基づいて正しいページング データを返すことを確認します。テストのデータを制御できるように、モックデータを PagingSource
コンストラクタに提供します。プリミティブ データ型のデフォルト値を渡すことはできますが、データベースや API 実装など、他のオブジェクトのモック バージョンを渡す必要があります。これにより、テスト対象の PagingSource
がモック データソースとやり取りするときに、モック データソースの出力を完全に制御できます。
特定の PagingSource
実装のコンストラクタ パラメータは、渡す必要があるテストデータの型を指定します。次の例の PagingSource
実装では、RedditApi
オブジェクトと、subreddit 名の String
が必要です。String
パラメータのデフォルト値を渡すことができますが、RedditApi
パラメータでは、テスト用のモック実装を作成する必要があります。
Kotlin
class ItemKeyedSubredditPagingSource( private val redditApi: RedditApi, private val subredditName: String ) : PagingSource<String, RedditPost>() { ... }
Java
public class ItemKeyedSubredditPagingSource extends RxPagingSource<String, RedditPost> { @NonNull private RedditApi redditApi; @NonNull private String subredditName; ItemKeyedSubredditPagingSource( @NotNull RedditApi redditApi, @NotNull String subredditName ) { this.redditApi = redditApi; this.subredditName = subredditName; ... } }
Java
public class ItemKeyedSubredditPagingSource extends ListenableFuturePagingSource<String, RedditPost> { @NonNull private RedditApi redditApi; @NonNull private String subredditName; @NonNull private Executor bgExecutor; public ItemKeyedSubredditPagingSource( @NotNull RedditApi redditApi, @NotNull String subredditName, @NonNull Executor bgExecutor ) { this.redditApi = redditApi; this.subredditName = subredditName; this.bgExecutor = bgExecutor; ... } }
この例の RedditApi
パラメータは、サーバー リクエストとレスポンス クラスを定義する Retrofit インターフェースです。モック バージョンはインターフェースを実装し、必要な関数をオーバーライドして、テストでモック オブジェクトがどのように反応するかを設定する便利なメソッドを提供します。
モック オブジェクトを配置したら、依存関係を設定して、テストの PagingSource
オブジェクトを初期化します。次の例では、テストポストのリストを使用して MockRedditApi
オブジェクトを初期化しています。
Kotlin
@OptIn(ExperimentalCoroutinesApi::class) class SubredditPagingSourceTest { private val postFactory = PostFactory() private val mockPosts = listOf( postFactory.createRedditPost(DEFAULT_SUBREDDIT), postFactory.createRedditPost(DEFAULT_SUBREDDIT), postFactory.createRedditPost(DEFAULT_SUBREDDIT) ) private val mockApi = MockRedditApi().apply { mockPosts.forEach { post -> addPost(post) } } @Test fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest { val pagingSource = ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT) } }
Java
public class SubredditPagingSourceTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); static { for (int i=0; i<3; i++) { mockPosts.add(post); mockApi.addPost(post); } } @After tearDown() { // run. mockApi.setFailureMsg(null); } @Test loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() { ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT); } } RedditPost post=postFactory.createRedditPost(DEFAULT_SUBREDDIT); public void Clear the failure message after each test public void throws InterruptedException ItemKeyedSubredditPagingSource pagingSource=new>
Java
public class SubredditPagingSourceTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); static { for (int i=0; i<3; i++) { mockPosts.add(post); mockApi.addPost(post); } } @After tearDown() { // run. mockApi.setFailureMsg(null); } @Test loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() ExecutionException, { ItemKeyedSubredditPagingSource( mockApi, DEFAULT_SUBREDDIT, CurrentThreadExecutor() ); } } RedditPost post=postFactory.createRedditPost(DEFAULT_SUBREDDIT); public void Clear the failure message after each test public void throws InterruptedException ItemKeyedSubredditPagingSource pagingSource=new new>
次に、PagingSource
オブジェクトをテストします。テストの主な焦点は load()
メソッドです。次の例は、load()
メソッドが特定の LoadParams
パラメータに対して正しいデータ、前のキー、次のキーを返すことを確認するアサーションを示しています。
Kotlin
@Test // Since load is a suspend function, runTest is used to ensure that it // runs on the test thread. fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest { val pagingSource = ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT) assertEquals( expected = Page( data = listOf(mockPosts[0], mockPosts[1]), prevKey = mockPosts[0].name, nextKey = mockPosts[1].name ), actual = pagingSource.load( Refresh( key = null, loadSize = 2, placeholdersEnabled = false ) ), ) }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() throws InterruptedException { ItemKeyedSubredditPagingSource pagingSource = new ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT); LoadParams.Refresh<String> refreshRequest = new LoadParams.Refresh<>(null, 2, false); pagingSource.loadSingle(refreshRequest) .test() .await() .assertValueCount(1) .assertValue(new LoadResult.Page<>( mockPosts.subList(0, 2), mockPosts.get(0).getName(), mockPosts.get(1).getName() )); }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() throws ExecutionException, InterruptedException { ItemKeyedSubredditPagingSource pagingSource = new ItemKeyedSubredditPagingSource( mockApi, DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingSource.LoadParams.Refresh<String> refreshRequest = new PagingSource.LoadParams.Refresh<>(null, 2, false); PagingSource.LoadResult<String, RedditPost> result = pagingSource.loadFuture(refreshRequest).get(); PagingSource.LoadResult<String, RedditPost> expected = new PagingSource.LoadResult.Page<>( mockPosts.subList(0, 2), mockPosts.get(0).getName(), mockPosts.get(1).getName() ); assertThat(result, equalTo(expected)); }
前の例は、アイテムキーによるページングを使用する PagingSource
のテストを示しています。使用しているデータソースがページキー設定されている場合、PagingSource
テストは異なります。主な違いは、load()
メソッドから予期される、前のキーと次のキーです。
Kotlin
@Test fun loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() = runTest { val pagingSource = PageKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT) assertEquals( expected = Page( data = listOf(mockPosts[0], mockPosts[1]), prevKey = null, nextKey = mockPosts[1].id ), actual = pagingSource.load( Refresh( key = null, loadSize = 2, placeholdersEnabled = false ) ), ) }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() throws InterruptedException { PageKeyedSubredditPagingSource pagingSource = new PageKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT); LoadParams.Refresh<String> refreshRequest = new LoadParams.Refresh<>(null, 2, false); pagingSource.loadSingle(refreshRequest) .test() .await() .assertValueCount(1) .assertValue(new LoadResult.Page<>( mockPosts.subList(0, 2), null, mockPosts.get(1).getId() )); }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() throws InterruptedException, ExecutionException { PageKeyedSubredditPagingSource pagingSource = new PageKeyedSubredditPagingSource( mockApi, DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingSource.LoadParams.Refresh<String> refreshRequest = new PagingSource.LoadParams.Refresh<>(null, 2, false); PagingSource.LoadResult<String, RedditPost> result = pagingSource.loadFuture(refreshRequest).get(); PagingSource.LoadResult<String, RedditPost> expected = new PagingSource.LoadResult.Page<>( mockPosts.subList(0, 2), null, mockPosts.get(1).getId() ); assertThat(result, equalTo(expected)); }
RemoteMediator のテスト
RemoteMediator
単体テストの目的は、load()
関数が正しい MediatorResult
を返すことを確認することです。データベースに挿入されるデータなどの副作用のテストは、統合テストに適しています。
最初のステップは、RemoteMediator
実装に必要な依存関係を判断することです。次の例は、Room データベース、Retrofit インターフェース、検索文字列を必要とする RemoteMediator
実装を示しています。
Kotlin
@OptIn(ExperimentalPagingApi::class) class PageKeyedRemoteMediator( private val db: RedditDb, private val redditApi: RedditApi, private val subredditName: String ) : RemoteMediator<Int, RedditPost>() { ... }
Java
public class PageKeyedRemoteMediator extends RxRemoteMediator<Integer, RedditPost> { @NonNull private RedditDb db; @NonNull private RedditPostDao postDao; @NonNull private SubredditRemoteKeyDao remoteKeyDao; @NonNull private RedditApi redditApi; @NonNull private String subredditName; public PageKeyedRemoteMediator( @NonNull RedditDb db, @NonNull RedditApi redditApi, @NonNull String subredditName ) { this.db = db; this.postDao = db.posts(); this.remoteKeyDao = db.remoteKeys(); this.redditApi = redditApi; this.subredditName = subredditName; ... } }
Java
public class PageKeyedRemoteMediator extends ListenableFutureRemoteMediator<Integer, RedditPost> { @NonNull private RedditDb db; @NonNull private RedditPostDao postDao; @NonNull private SubredditRemoteKeyDao remoteKeyDao; @NonNull private RedditApi redditApi; @NonNull private String subredditName; @NonNull private Executor bgExecutor; public PageKeyedRemoteMediator( @NonNull RedditDb db, @NonNull RedditApi redditApi, @NonNull String subredditName, @NonNull Executor bgExecutor ) { this.db = db; this.postDao = db.posts(); this.remoteKeyDao = db.remoteKeys(); this.redditApi = redditApi; this.subredditName = subredditName; this.bgExecutor = bgExecutor; ... } }
PagingSource のテスト セクションに示すように、Retrofit インターフェースと検索文字列を指定できます。Room データベースのモック バージョンを提供することは非常に複雑です。そのため完全なモック バージョンではなく、データベースのインメモリ実装を提供する方が簡単です。Room データベースを作成するには Context
オブジェクトが必要なため、この RemoteMediator
テストを androidTest
ディレクトリに配置し、AndroidJUnit4 テストランナーで実行して、テストアプリのコンテキストにアクセスできるようにする必要があります。インストルメント化テストの詳細については、インストルメント化単体テストを作成するをご覧ください。
状態がテスト関数間でリークしないようにティアダウン関数を定義します。これにより、テスト実行間で一貫した結果が得られます。
Kotlin
@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() } }
Java
@RunWith(AndroidJUnit4.class) public class PageKeyedRemoteMediatorTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); private RedditDb mockDb = RedditDb.Companion.create( ApplicationProvider.getApplicationContext(), true ); static { for (int i=0; i<3; i++) { RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT); mockPosts.add(post); } } @After public void tearDown() { mockDb.clearAllTables(); // Clear the failure message after each test run. mockApi.setFailureMsg(null); // Clear out posts after each test run. mockApi.clearPosts(); } }
Java
@RunWith(AndroidJUnit4.class) public class PageKeyedRemoteMediatorTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); private RedditDb mockDb = RedditDb.Companion.create( ApplicationProvider.getApplicationContext(), true ); static { for (int i=0; i<3; i++) { RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT); mockPosts.add(post); } } @After public void tearDown() { mockDb.clearAllTables(); // Clear the failure message after each test run. mockApi.setFailureMsg(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()
関数をテストします。
Kotlin
@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 } }
Java
@Test public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() throws InterruptedException { // Add mock results for the API to return. for (RedditPost post: mockPosts) { mockApi.addPost(post); } PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success && ((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == false); }
Java
@Test public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() throws InterruptedException, ExecutionException { // Add mock results for the API to return. for (RedditPost post: mockPosts) { mockApi.addPost(post); } PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class)); assertFalse(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached()); }
第 2 のテストでは、mockApi
が空の結果を返す必要があります。各テストの実行後に mockApi
からデータを消去するため、デフォルトでは空の結果が返されます。
Kotlin
@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 } }
Java
@Test public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() throws InterruptedException() { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success && ((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == true); }
Java
@Test public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() throws InterruptedException, ExecutionException { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class)); assertTrue(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached()); }
最後のテストでは、load()
関数が MediatorResult.Error
を正しく返すことをテストで確認できるように、mockApi
が例外をスローする必要があります。
Kotlin
@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 } }
Java
@Test public void refreshLoadReturnsErrorResultWhenErrorOccurs() throws InterruptedException { // Set up failure message to throw exception from the mock API. mockApi.setFailureMsg("Throw test failure"); PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Error); }
Java
@Test public void refreshLoadReturnsErrorResultWhenErrorOccurs() throws InterruptedException, ExecutionException { // Set up failure message to throw exception from the mock API. mockApi.setFailureMsg("Throw test failure"); PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Error.class)); }
変換のテスト
PagingData
ストリームに適用する変換を対象とする単体テストも作成する必要があります。Paging 実装でデータのマッピングまたはフィルタリングを行う場合は、そのような変換をテストして、想定どおりに機能することを確認する必要があります。PagingData
ストリームの出力に直接アクセスすることはできないため、変換テストでは AsyncPagingDataDiffer
を使用する必要があります。
次の例は、テスト目的で PagingData
オブジェクトに適用される基本的な変換をいくつか示しています。
Kotlin
fun PagingData<Int>.myHelperTransformFunction(): PagingData<Int> { return this.map { item -> item * item }.filter { item -> item % 2 == 0 } }
Java
public PagingData<Integer> myHelperTransformFunction(PagingData<Integer> pagingData) { PagingData<Integer> mappedData = PagingDataTransforms.map( pagingData, new CurrentThreadExecutor(), ((item) -> item * item) ); return PagingDataTransforms.filter( mappedData, new CurrentThreadExecutor(), ((item) -> item % 2 == 0) ); }
Java
public PagingData<Integer> myHelperTransformFunction(PagingData<Integer> pagingData) { PagingData<Integer> mappedData = PagingDataTransforms.map( pagingData, new CurrentThreadExecutor(), ((item) -> item * item) ); return PagingDataTransforms.filter( mappedData, new CurrentThreadExecutor(), ((item) -> item % 2 == 0) ); }
AsyncPagingDataDiffer
オブジェクトには複数のパラメータが必要ですが、そのほとんどは、テスト目的の場合は空の実装にすることができます。これらのパラメータのうちの 2 つは、DiffUtil.ItemCallback
の実装と ListUpdateCallback
の実装です。
Kotlin
class NoopListCallback : ListUpdateCallback { override fun onChanged(position: Int, count: Int, payload: Any?) {} override fun onMoved(fromPosition: Int, toPosition: Int) {} override fun onInserted(position: Int, count: Int) {} override fun onRemoved(position: Int, count: Int) {} } class MyDiffCallback : DiffUtil.ItemCallback<Int>() { override fun areItemsTheSame(oldItem: Int, newItem: Int): Boolean { return oldItem == newItem } override fun areContentsTheSame(oldItem: Int, newItem: Int): Boolean { return oldItem == newItem } }
Java
class NoopListCallback implements ListUpdateCallback { @Override public void onInserted(int position, int count) {} @Override public void onRemoved(int position, int count) {} @Override public void onMoved(int fromPosition, int toPosition) {} @Override public void onChanged( int position, int count, @Nullable Object payload ) { } } class MyDiffCallback extends DiffUtil.ItemCallback<Integer> { @Override public boolean areItemsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } }
Java
class NoopListCallback implements ListUpdateCallback { @Override public void onInserted(int position, int count) {} @Override public void onRemoved(int position, int count) {} @Override public void onMoved(int fromPosition, int toPosition) {} @Override public void onChanged( int position, int count, @Nullable Object payload ) { } } class MyDiffCallback extends DiffUtil.ItemCallback<Integer> { @Override public boolean areItemsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } }
これらの依存関係を設定して、変換テストを作成できます。変換テストでは、次の手順を実施する必要があります。
- テストデータで
PagingData
を初期化します。 PagingData
に対して変換を実行します。- 変換されたデータを differ に渡します。
- differ によってデータが解析されたら、differ 出力のスナップショットにアクセスして、データが正しいことを確認します。
次の例は、このプロセスを示しています。
Kotlin
@ExperimentalCoroutinesApi class PagingDataTransformTest { private val testScope = TestScope() private val testDispatcher = StandardTestDispatcher(testScope.testScheduler) @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After fun tearDown() { Dispatchers.resetMain() } @Test fun differTransformsData() = testScope.runTest { val data = PagingData.from(listOf(1, 2, 3, 4)).myHelperTransformFunction() val differ = AsyncPagingDataDiffer( diffCallback = MyDiffCallback(), updateCallback = NoopListCallback(), workerDispatcher = Dispatchers.Main ) // You don't need to use lifecycleScope.launch() if you're using // PagingData.from() differ.submitData(data) // Wait for transforms and the differ to process all updates. advanceUntilIdle() assertEquals(listOf(4, 16), differ.snapshot().items) } }
Java
List<Integer> data = Arrays.asList(1, 2, 3, 4); PagingData<Integer> pagingData = PagingData.from(data); PagingData<Integer> transformedData = myHelperTransformFunction(pagingData); Executor executor = new CurrentThreadExecutor(); CoroutineDispatcher dispatcher = ExecutorsKt.from(executor); AsyncPagingDataDiffer<Integer> differ = new AsyncPagingDataDiffer<Integer>( new MyDiffCallback(), new NoopListCallback(), dispatcher, dispatcher ); TestLifecycleOwner lifecycleOwner = new TestLifecycleOwner(Lifecycle.State.RESUMED, dispatcher); differ.submitData(lifecycleOwner.getLifecycle(), transformedData); // Wait for submitData() to present some data. while (differ.getItemCount() == 0) { Thread.sleep(100); } assertEquals(Arrays.asList(4, 16), differ.snapshot().getItems());
Java
List<Integer> data = Arrays.asList(1, 2, 3, 4); PagingData<Integer> pagingData = PagingData.from(data); PagingData<Integer> transformedData = myHelperTransformFunction(pagingData); Executor executor = new CurrentThreadExecutor(); CoroutineDispatcher dispatcher = ExecutorsKt.from(executor); AsyncPagingDataDiffer<Integer> differ = new AsyncPagingDataDiffer<Integer>( new MyDiffCallback(), new NoopListCallback(), dispatcher, dispatcher ); TestLifecycleOwner lifecycleOwner = new TestLifecycleOwner(Lifecycle.State.RESUMED, dispatcher); differ.submitData(lifecycleOwner.getLifecycle(), transformedData); // Wait for submitData() to present some data. while (differ.getItemCount() == 0) { Thread.sleep(100); } assertEquals(Arrays.asList(4, 16), differ.snapshot().getItems());
エンドツーエンド テスト
単体テストでは、個々の Paging コンポーネントが独立して動作することが保証されますが、エンドツーエンド テストでは、アプリ全体が動作する確実性が増します。これらのテストはまだモックの依存関係を必要としますが、通常はアプリコードの大部分をカバーします。
このセクションの例では、テストでネットワークを使用しないように、モック API の依存関係を使用します。モック API は、一貫性のあるテストデータのセットを返すように設定されているため、繰り返し可能なテストになります。各依存関係が何を行うか、出力がどの程度一貫しているか、テストにどの程度の忠実度が必要かに基づいて、モック実装に切り替える依存関係を決定します。
依存関係のモック バージョンに簡単に切り替えられるようにコードを記述します。次の例では、基本的なサービス ロケータ実装を使用して、必要に応じて依存関係を指定し、変更します。大規模なアプリでは、Hilt のような依存関係挿入ライブラリを使用することで、より複雑な依存関係グラフを管理できます。
Kotlin
class RedditActivityTest { companion object { private const val TEST_SUBREDDIT = "test" } private val postFactory = PostFactory() private val mockApi = MockRedditApi().apply { addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)) addPost(postFactory.createRedditPost(TEST_SUBREDDIT)) addPost(postFactory.createRedditPost(TEST_SUBREDDIT)) } @Before fun init() { val app = ApplicationProvider.getApplicationContext<Application>() // Use a controlled service locator with a mock API. ServiceLocator.swap( object : DefaultServiceLocator(app = app, useInMemoryDb = true) { override fun getRedditApi(): RedditApi = mockApi } ) } }
Java
public class RedditActivityTest { public static final String TEST_SUBREDDIT = "test"; private static PostFactory postFactory = new PostFactory(); private static MockRedditApi mockApi = new MockRedditApi(); static { mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); } @Before public void setup() { Application app = ApplicationProvider.getApplicationContext(); // Use a controlled service locator with a mock API. ServiceLocator.Companion.swap( new DefaultServiceLocator(app, true) { @NotNull @Override public RedditApi getRedditApi() { return mockApi; } } ); } }
Java
public class RedditActivityTest { public static final String TEST_SUBREDDIT = "test"; private static PostFactory postFactory = new PostFactory(); private static MockRedditApi mockApi = new MockRedditApi(); static { mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); } @Before public void setup() { Application app = ApplicationProvider.getApplicationContext(); // Use a controlled service locator with a mock API. ServiceLocator.Companion.swap( new DefaultServiceLocator(app, true) { @NotNull @Override public RedditApi getRedditApi() { return mockApi; } } ); } }
テスト構造を設定したら、次に、Pager
実装によって返されたデータが正しいことを確認します。一方のテストでは、ページが最初に読み込まれるときに Pager
オブジェクトがデフォルト データを読み込むことを確認し、他方のテストでは、Pager
オブジェクトがユーザー入力に基づいて追加のデータを正しく読み込むことを確認します。次の例では、ユーザーが別の subreddit を入力して検索したときに、Pager
オブジェクトが API から返された正しい数のアイテムで RecyclerView.Adapter
を更新することをテストで確認します。
Kotlin
@Test fun loadsTheDefaultResults() { ActivityScenario.launch(RedditActivity::class.java) onView(withId(R.id.list)).check { view, noViewFoundException -> if (noViewFoundException != null) { throw noViewFoundException } val recyclerView = view as RecyclerView assertEquals(1, recyclerView.adapter?.itemCount) } } @Test // Verify that the default data is swapped out when the user searches for a // different subreddit. fun loadsTheTestResultsWhenSearchingForSubreddit() { ActivityScenario.launch(RedditActivity::class.java ) onView(withId(R.id.list)).check { view, noViewFoundException -> if (noViewFoundException != null) { throw noViewFoundException } val recyclerView = view as RecyclerView // Verify that it loads the default data first. assertEquals(1, recyclerView.adapter?.itemCount) } // Search for test subreddit instead of default to trigger new data load. onView(withId(R.id.input)).perform( replaceText(TEST_SUBREDDIT), pressKey(KeyEvent.KEYCODE_ENTER) ) onView(withId(R.id.list)).check { view, noViewFoundException -> if (noViewFoundException != null) { throw noViewFoundException } val recyclerView = view as RecyclerView assertEquals(2, recyclerView.adapter?.itemCount) } }
Java
@Test public void loadsTheDefaultResults() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(1, recyclerView.getAdapter().getItemCount()); }); } @Test // Verify that the default data is swapped out when the user searches for a // different subreddit. public void loadsTheTestResultsWhenSearchingForSubreddit() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; // Verify that it loads the default data first. assertEquals(1, recyclerView.getAdapter().getItemCount()); }); // Search for test subreddit instead of default to trigger new data load. onView(withId(R.id.input)).perform( replaceText(TEST_SUBREDDIT), pressKey(KeyEvent.KEYCODE_ENTER) ); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(2, recyclerView.getAdapter().getItemCount()); }); }
Java
@Test public void loadsTheDefaultResults() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(1, recyclerView.getAdapter().getItemCount()); }); } @Test // Verify that the default data is swapped out when the user searches for a // different subreddit. public void loadsTheTestResultsWhenSearchingForSubreddit() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; // Verify that it loads the default data first. assertEquals(1, recyclerView.getAdapter().getItemCount()); }); // Search for test subreddit instead of default to trigger new data load. onView(withId(R.id.input)).perform( replaceText(TEST_SUBREDDIT), pressKey(KeyEvent.KEYCODE_ENTER) ); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(2, recyclerView.getAdapter().getItemCount()); }); }
インストルメント化テストでは、データが UI に正しく表示されることを確認する必要があります。これを行うには、RecyclerView.Adapter
に正しい数のアイテムが存在することを確認するか、個々の行ビューを反復処理してデータの形式が正しいことを確認します。