La implementación de la biblioteca de Paging en tu app debe sincronizarse con una sólida estrategia de prueba. Debes probar los componentes de carga de datos, como PagingSource
y RemoteMediator
, para asegurarte de que funcionan como se espera. Además, debes escribir pruebas de extremo a extremo para verificar que todos los componentes de la implementación de Paging funcionen correctamente en conjunto, sin efectos secundarios imprevistos.
En esta guía, se explica cómo probar las fuentes de datos de paginación de forma aislada, cómo probar las transformaciones que apliques a los datos paginados y cómo escribir pruebas de extremo a extremo para la implementación completa de Paging. Los ejemplos de esta guía se basan en el Ejemplo de Paging con red.
Pruebas de la capa del repositorio
Escribe pruebas de unidades para los componentes de la capa de tu repositorio y asegúrate de que carguen correctamente los datos de tus fuentes de datos. Proporciona versiones ficticias de dependencias para verificar que los componentes en prueba funcionen correctamente en forma aislada. Los componentes principales que debes probar en la capa del repositorio son PagingSource
y RemoteMediator
.
Pruebas de PagingSource
Las pruebas de unidades para tus clases PagingSource
incluyen configurar la instancia PagingSource
y verificar que la función load()
muestre los datos paginados correctos en función de un argumento LoadParams
. Proporciona datos ficticios al constructor PagingSource
para que puedas tener el control de los datos en tus pruebas. Puedes pasar un valor predeterminado para los tipos de datos primitivos, pero debes pasar una versión ficticia para otros objetos como, por ejemplo, una base de datos o una implementación de la API. Esto te brinda control total sobre la salida de la fuente de datos ficticia cuando el objeto PagingSource
en prueba interactúa con ella.
Los parámetros de constructor de tu implementación PagingSource
específica determinan el tipo de datos de prueba que debes pasar. En el siguiente ejemplo, la implementación de PagingSource
requiere un objeto RedditApi
y una String
para el nombre de subreddit. Si bien puedes pasar un valor predeterminado para el parámetro String
, en el caso del parámetro RedditApi
debes crear una implementación ficticia para pruebas.
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; ... } }
En este ejemplo, el parámetro RedditApi
es una interfaz de Retrofit que define las solicitudes del servidor y las clases de respuesta. Una versión ficticia puede implementar la interfaz, anular las funciones necesarias y proporcionar métodos útiles para configurar cómo debe reaccionar el objeto ficticio en las pruebas.
Después de implementar los objetos ficticios, configura las dependencias y, luego, inicializa el objeto PagingSource
en la prueba. En el siguiente ejemplo, se muestra cómo inicializar el objeto MockRedditApi
con una lista de publicaciones de prueba:
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>
Por último, debes probar el objeto PagingSource
. El método load()
es el objetivo principal de las pruebas. En el siguiente ejemplo, se observa una aserción que verifica que los datos, la clave anterior y la clave siguiente que muestra el método load()
para un parámetro LoadParams
determinado sean correctos:
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)); }
En el ejemplo anterior, se muestra cómo probar un PagingSource
que utiliza paginación con elementos protegidos por clave. Si la fuente de datos que usas cuenta con página protegidas por clave, las pruebas de PagingSource
serán diferentes. La diferencia principal es la clave anterior y la siguiente que se esperan del método 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)); }
Pruebas de RemoteMediator
El objetivo de las pruebas de unidades de RemoteMediator
es verificar que la función load()
muestre el MediatorResult
correcto.
Las pruebas de efectos secundarios, por ejemplo, los datos que se insertan en la base de datos, son más adecuadas para las pruebas de integración.
El primer paso consiste en determinar qué dependencias necesita tu implementación de RemoteMediator
. En el siguiente ejemplo, se muestra una implementación de RemoteMediator
que requiere una base de datos de Room, una interfaz de Retrofit y una string de búsqueda:
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; ... } }
Puedes proporcionar la interfaz de Retrofit y la string de búsqueda como se indica en la sección Pruebas de PagingSource. Proporcionar una versión ficticia de la base de datos de Room es muy complicado, así que quizás sea más fácil realizar una implementación en la memoria de la base de datos en lugar de una versión ficticia completa. Como para crear una base de datos de Room se necesita un objeto Context
, debes colocar esta prueba de RemoteMediator
en el directorio androidTest
y ejecutarla con AndroidJUnit4, el corredor de prueba, para que tenga acceso a un contexto de aplicación de prueba. Si quieres obtener más información sobre las pruebas instrumentadas, consulta Cómo compilar pruebas de unidades instrumentadas.
Define las funciones de anulación a fin de asegurarte de que el estado no se fugue entre las funciones de prueba. Esto garantiza resultados coherentes entre las ejecuciones de prueba.
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(); } }
Por último, debes probar la función load()
. En este ejemplo, hay tres casos para probar:
- El primer caso es cuando
mockApi
muestra datos válidos. La funciónload()
debe mostrarMediatorResult.Success
y la propiedadendOfPaginationReached
debe serfalse
. - El segundo caso es cuando
mockApi
muestra una respuesta afirmativa, pero los datos están vacíos. La funciónload()
debe mostrarMediatorResult.Success
, y la propiedadendOfPaginationReached
debe sertrue
. - El tercer caso es cuando
mockApi
arroja una excepción al recuperar los datos. La funciónload()
debe mostrarMediatorResult.Error
.
Sigue estos pasos para probar el primer caso:
- Configura la
mockApi
con los datos de entrada que se mostrarán. - Inicializa el objeto
RemoteMediator
. - Prueba la función
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()); }
La segunda prueba requiere que mockApi
muestre un resultado vacío. Como borras los datos de mockApi
después de cada ejecución de prueba, se mostrará un resultado vacío de forma predeterminada.
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()); }
La prueba final requiere que mockApi
arroje una excepción para que la prueba pueda verificar que la función load()
muestra correctamente a MediatorResult.Error
.
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)); }
Pruebas de transformación
También debes escribir pruebas de unidades a fin de abarcar las transformaciones que apliques al flujo de PagingData
. Si tu implementación de Paging realiza asignaciones o filtrado de datos, debes probar estas transformaciones para asegurarte de que funcionen como se espera. En estas pruebas, debes usar AsyncPagingDataDiffer
, dado que no se puede acceder directamente al resultado del flujo de PagingData
.
En el siguiente ejemplo, se muestran algunas transformaciones básicas que se aplican a un objeto PagingData
para probarlas:
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) ); }
El objeto AsyncPagingDataDiffer
requiere varios parámetros, pero la mayoría pueden ser implementaciones vacías con el fin de realizar pruebas. Dos de estos parámetros son implementaciones de DiffUtil.ItemCallback
y 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); } }
Una vez implementadas estas dependencias, puedes escribir las pruebas de transformación, que deben seguir los siguientes pasos:
- Inicializa el objeto
PagingData
con los datos de prueba. - Ejecuta las transformaciones en el objeto
PagingData
. - Pasa los datos transformados al objeto de diferencias.
- Después de analizar los datos, accede a una instantánea del resultado del objeto de diferencias y verifica que los datos sean correctos.
En el siguiente ejemplo, se muestra este proceso:
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());
Pruebas de extremo a extremo
Las pruebas de unidades garantizan que los componentes individuales de Paging funcionen de forma aislada, pero las pruebas de extremo a extremo brindan más confianza que la aplicación en conjunto. Esas pruebas necesitarán algunas dependencias ficticias, pero, en general, cubrirán la mayor parte del código de tu app.
En el ejemplo de esta sección, se usa una dependencia de API ficticia para evitar el uso de la red en las pruebas. La API ficticia está configurada para mostrar un conjunto coherente de datos de prueba, lo que genera pruebas repetibles. Decide qué dependencias cambiar por implementaciones ficticias según lo que haga cada dependencia, la coherencia de su resultado y la fidelidad que necesitas en tus pruebas.
Escribe el código de manera que te permita cambiar con facilidad a versiones ficticias de tus dependencias. En el siguiente ejemplo, se usa una implementación de localizador de servicios básica para proporcionar y cambiar dependencias según sea necesario. En apps más grandes, el uso de una biblioteca de inyección de dependencias como Hilt puede ayudar a administrar gráficos de dependencias más complejos.
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; } } ); } }
Después de configurar la estructura de prueba, por último, debes verificar que los datos que muestra la implementación de Pager
sean correctos. Una prueba debe garantizar que el objeto Pager
cargue los datos predeterminados cuando la página se cargue por primera vez, y otra debe verificar que el objeto Pager
cargue correctamente datos adicionales cuando el usuario los ingresa. En el siguiente ejemplo, la prueba verifica que el objeto Pager
actualice RecyclerView.Adapter
con la cantidad correcta de elementos que muestra la API cuando el usuario ingresa un subreddit diferente para buscar.
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()); }); }
Las pruebas instrumentadas deben verificar que los datos se muestren de forma correcta en la IU. Para ello, verifica que en RecyclerView.Adapter
exista la cantidad correcta de elementos o itera a través de las vistas de filas individuales y verifica que los datos tengan el formato correcto.