Konzepte und Jetpack Compose-Implementierung
Die Implementierung der Paging-Bibliothek in Ihrer App sollte mit einer robusten Teststrategie einhergehen. Sie sollten Datenladekomponenten wie
PagingSource und RemoteMediator testen, um sicherzustellen, dass sie wie
erwartet funktionieren. Außerdem sollten Sie End-to-End-Tests schreiben, um zu prüfen, ob alle Komponenten in Ihrer Paging-Implementierung korrekt zusammenarbeiten und keine unerwarteten Nebenwirkungen auftreten.
In diesem Leitfaden wird erläutert, wie Sie die Paging-Bibliothek in der Datenschicht Ihrer App testen und wie Sie End-to-End-Tests für Ihre gesamte Paging-Implementierung schreiben.
Tests der Datenschicht
Schreiben Sie Unit-Tests für die Komponenten in Ihrer Datenschicht, um sicherzustellen, dass sie die Daten ordnungsgemäß aus Ihren Datenquellen laden. Stellen Sie gefälschte Versionen von
Abhängigkeiten bereit, um zu prüfen, ob die getesteten Komponenten isoliert korrekt funktionieren. Eine der Komponenten, die Sie in der Repository-Schicht testen müssen, ist RemoteMediator.
RemoteMediator-Tests
Ziel der RemoteMediator Unit-Tests ist es, zu prüfen, ob die load()
Funktion das richtige MediatorResult zurückgibt. Tests auf Nebenwirkungen, z. B. das Einfügen von Daten in die Datenbank, eignen sich besser für Integrationstests.
Der erste Schritt besteht darin, zu ermitteln, welche Abhängigkeiten Ihre RemoteMediator-Implementierung benötigt. Im folgenden Beispiel wird eine RemoteMediator-Implementierung gezeigt, die eine Room-Datenbank, eine Retrofit-Schnittstelle und eine Suchanfrage erfordert:
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;
...
}
}
Sie können die Retrofit-Schnittstelle und die Suchanfrage wie in
den PagingSource-Tests im Abschnitt Paging-Implementierung testen beschrieben bereitstellen.
Die Bereitstellung einer Mock-Version der Room-Datenbank ist sehr aufwendig. Daher ist es möglicherweise
einfacher, eine In-Memory-Implementierung der Datenbank anstelle einer
vollständigen Mock-Version bereitzustellen. Da zum Erstellen einer Room-Datenbank ein Context
Objekt erforderlich ist, müssen Sie diesen RemoteMediator Test im Verzeichnis androidTest
platzieren und mit dem AndroidJUnit4-Testrunner ausführen, damit er Zugriff auf einen
Testanwendungskontext hat. Weitere Informationen zu instrumentierten Tests finden Sie unter
Instrumentierte Unit-Tests erstellen.
Definieren Sie Bereinigungsfunktionen, um zu verhindern, dass der Status zwischen Testfunktionen weitergegeben wird. So werden konsistente Ergebnisse zwischen Testläufen gewährleistet.
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();
}
}
Der nächste Schritt besteht darin, die Funktion load() zu testen. In diesem Beispiel gibt es drei zu testende Fälle:
- Der erste Fall liegt vor, wenn
mockApigültige Daten zurückgibt. Die Funktionload()sollteMediatorResult.Successzurückgeben und die PropertyendOfPaginationReachedsolltefalsesein. - Der zweite Fall liegt vor, wenn
mockApieine erfolgreiche Antwort zurückgibt, die zurückgegebenen Daten jedoch leer sind. Die FunktionloadsollteMediatorResult.Successzurückgeben und die PropertyendOfPaginationReachedsolltetruesein. - Der dritte Fall liegt vor, wenn
mockApibeim Abrufen der Daten eine Ausnahme auslöst. Die Funktionload()sollteMediatorResult.Errorzurückgeben.
Führen Sie die folgenden Schritte aus, um den ersten Fall zu testen:
- Richten Sie
mockApimit den zurückzugebenden Post-Daten ein. - Initialisieren Sie das
RemoteMediator-Objekt. - Testen Sie die Funktion
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());
}
Für den zweiten Test muss mockApi ein leeres Ergebnis zurückgeben. Da Sie die Daten nach jedem Testlauf aus mockApi löschen, wird standardmäßig ein leeres Ergebnis zurückgegeben.
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());
}
Für den letzten Test muss mockApi eine Ausnahme auslösen, damit der Test prüfen kann, ob die Funktion load() korrekt MediatorResult.Error zurückgibt.
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));
}
End-to-End-Tests
Unit-Tests bieten die Gewissheit, dass einzelne Paging-Komponenten isoliert funktionieren. End-to-End-Tests geben jedoch mehr Sicherheit, dass die Anwendung als Ganzes funktioniert. Für diese Tests sind weiterhin einige Mock-Abhängigkeiten erforderlich, aber im Allgemeinen decken sie den größten Teil Ihres App-Codes ab.
Im Beispiel in diesem Abschnitt wird eine Mock-API-Abhängigkeit verwendet, um die Verwendung des Netzwerks in Tests zu vermeiden. Die Mock-API ist so konfiguriert, dass sie einen konsistenten Satz von Testdaten zurückgibt, was zu wiederholbaren Tests führt. Entscheiden Sie, welche Abhängigkeiten durch Mock-Implementierungen ersetzt werden sollen, je nachdem, was die einzelnen Abhängigkeiten tun, wie konsistent ihre Ausgabe ist und wie viel Genauigkeit Sie von Ihren Tests benötigen.
Schreiben Sie Ihren Code so, dass Sie Mock-Versionen Ihrer Abhängigkeiten einfach austauschen können. Im folgenden Beispiel wird eine einfache Service Locator Implementierung verwendet, um Abhängigkeiten nach Bedarf bereitzustellen und zu ändern. In größeren Apps kann eine Dependency Injection-Bibliothek wie Hilt helfen, komplexere Abhängigkeitsgraphen zu verwalten.
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 (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;
}
}
);
}
}
Nachdem Sie die Teststruktur eingerichtet haben, müssen Sie prüfen, ob die von der Pager-Implementierung zurückgegebenen Daten korrekt sind. Ein Test sollte sicherstellen, dass das Pager-Objekt die Standarddaten lädt, wenn die Seite zum ersten Mal geladen wird. Ein weiterer Test sollte prüfen, ob das Pager-Objekt zusätzliche Daten basierend auf der Nutzereingabe korrekt lädt.
Im folgenden Beispiel wird geprüft, ob das Pager-Objekt RecyclerView.Adapter mit der richtigen Anzahl von Elementen aktualisiert, die von der API zurückgegeben werden, wenn der Nutzer ein anderes Subreddit für die Suche eingibt.
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 (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());
});
}
Instrumentierte Tests sollten prüfen, ob die Daten in der UI korrekt angezeigt werden. Prüfen Sie dazu entweder, ob die richtige Anzahl von Elementen in RecyclerView.Adapter vorhanden ist, oder durchlaufen Sie die einzelnen Zeilenansichten und prüfen Sie, ob die Daten korrekt formatiert sind.