Концепции и реализация Jetpack Compose
Внедрение библиотеки Paging в ваше приложение должно сопровождаться надежной стратегией тестирования. Вам следует протестировать компоненты загрузки данных, такие как PagingSource и RemoteMediator чтобы убедиться, что они работают должным образом. Вам также следует написать сквозные тесты, чтобы проверить, что все компоненты вашей реализации Paging корректно взаимодействуют друг с другом без неожиданных побочных эффектов.
В этом руководстве объясняется, как тестировать библиотеку Paging на уровне данных вашего приложения, а также как писать сквозные тесты для всей вашей реализации Paging.
Тесты уровня данных
Напишите модульные тесты для компонентов в слое данных, чтобы убедиться, что они корректно загружают данные из ваших источников данных. Предоставьте фиктивные версии зависимостей, чтобы проверить корректность работы тестируемых компонентов в изолированном режиме. Одним из компонентов, который необходимо протестировать в слое репозитория, является RemoteMediator .
Тесты RemoteMediator
Цель модульных тестов RemoteMediator — проверить, что функция load() возвращает правильный MediatorResult . Тесты на побочные эффекты, такие как вставка данных в базу данных, лучше подходят для интеграционных тестов .
Первый шаг — определить, какие зависимости необходимы для вашей реализации RemoteMediator . Следующий пример демонстрирует реализацию RemoteMediator , которая требует базу данных Room, интерфейс Retrofit и строку поиска:
Java (RxJava)
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 (Guava/LiveData)
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;
...
}
}
Вы можете предоставить интерфейс Retrofit и строку поиска, как показано в разделе тестов PagingSource руководства «Тестирование реализации пейджинга» . Предоставление фиктивной версии базы данных Room — довольно сложная задача, поэтому проще предоставить реализацию базы данных в памяти, чем полную фиктивную версию. Поскольку для создания базы данных Room требуется объект Context , необходимо поместить этот тест RemoteMediator в каталог androidTest и выполнить его с помощью средства запуска тестов AndroidJUnit4, чтобы оно имело доступ к контексту тестового приложения. Дополнительную информацию об инструментированных тестах см. в разделе «Создание инструментированных модульных тестов» .
Определите функции завершения работы, чтобы гарантировать отсутствие утечки состояния между тестовыми функциями. Это обеспечит согласованность результатов между запусками тестов.
Java (RxJava)
@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 (Guava/LiveData)
@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() . В этом примере необходимо протестировать три случая:
- Первый случай — когда
mockApiвозвращает корректные данные. Функцияload()должна возвращатьMediatorResult.Success, а свойствоendOfPaginationReachedдолжно бытьfalse. - Второй случай — когда
mockApiвозвращает успешный ответ, но возвращаемые данные пусты. Функцияloadдолжна возвращатьMediatorResult.Success, а свойствоendOfPaginationReachedдолжно бытьtrue. - Третий случай — это когда
mockApiвыбрасывает исключение при получении данных. Функцияload()должна возвращатьMediatorResult.Error.
Выполните следующие шаги для проверки первого случая:
- Настройте
mockApi, указав данные POST-запроса, которые должны быть возвращены. - Инициализируйте объект
RemoteMediator. - Проверьте функцию
load.
Java (RxJava)
@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 (Guava/LiveData)
@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());
}
Второй тест требует, чтобы mockApi возвращал пустой результат. Поскольку данные из mockApi очищаются после каждого запуска теста, по умолчанию он будет возвращать пустой результат.
Java (RxJava)
@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 (Guava/LiveData)
@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());
}
Для финального теста требуется, чтобы mockApi генерировал исключение, чтобы тест мог убедиться, что функция load() корректно возвращает MediatorResult.Error .
Java (RxJava)
@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 (Guava/LiveData)
@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));
}
Сквозные тесты
Модульные тесты гарантируют, что отдельные компоненты пагинации работают изолированно, но сквозные тесты обеспечивают большую уверенность в работе приложения в целом. Для этих тестов по-прежнему потребуются некоторые фиктивные зависимости, но, как правило, они охватывают большую часть кода вашего приложения.
В примере в этом разделе используется фиктивная зависимость API, чтобы избежать использования сети в тестах. Фиктивный API настроен на возврат согласованного набора тестовых данных, что обеспечивает воспроизводимость тестов. Решите, какие зависимости заменить на фиктивные реализации, исходя из того, что делает каждая зависимость, насколько согласованным является ее вывод и насколько точным должен быть ваш тест.
Пишите код таким образом, чтобы можно было легко заменять фиктивные версии зависимостей. В следующем примере используется базовая реализация локатора сервисов для предоставления и изменения зависимостей по мере необходимости. В более крупных приложениях использование библиотеки внедрения зависимостей, такой как Hilt, может помочь в управлении более сложными графами зависимостей.
Котлин
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 (RxJava)
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 (Guava/LiveData)
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 корректно загружает дополнительные данные на основе пользовательского ввода.
В следующем примере тест проверяет, обновляет ли объект Pager объект RecyclerView.Adapter правильным количеством элементов, возвращенных API, когда пользователь вводит для поиска другой сабреддит.
Котлин
@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 (RxJava)
@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 (Guava/LiveData)
@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());
});
}
Инструментальные тесты должны проверять корректность отображения данных в пользовательском интерфейсе. Это можно сделать либо проверив наличие правильного количества элементов в RecyclerView.Adapter , либо пройдясь по отдельным строкам и проверив правильность форматирования данных.