מושגים ויישום ב-Jetpack פיתוח נייטיב
הטמעה של ספריית Paging באפליקציה צריכה להתבצע בשילוב עם אסטרטגיית בדיקה חזקה. כדאי לבדוק רכיבים לטעינת נתונים כמו PagingSource ו-RemoteMediator כדי לוודא שהם פועלים כמו שציפיתם. כדאי גם לכתוב בדיקות מקצה לקצה כדי לוודא שכל הרכיבים בהטמעה של הפעולות להחלפת דפים פועלים יחד בצורה תקינה, בלי תופעות לוואי לא צפויות.
במדריך הזה מוסבר איך לבדוק את ספריית 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 במאמר בדיקת ההטמעה של Paging.
יצירת גרסת דמה של מסד הנתונים של Room היא תהליך מורכב, ולכן יכול להיות שיהיה קל יותר לספק הטמעה בזיכרון של מסד הנתונים במקום גרסת דמה מלאה. כדי ליצור מסד נתונים של Room צריך אובייקט Context. לכן, צריך למקם את הבדיקה RemoteMediator בספרייה androidTest ולהריץ אותה באמצעות AndroidJUnit4 test runner כדי שתהיה לה גישה להקשר של אפליקציית הבדיקה. מידע נוסף על בדיקות עם מכשור זמין במאמר בנושא יצירת בדיקות יחידה עם מכשור.
מגדירים פונקציות לניקוי כדי לוודא שהמצב לא דולף בין פונקציות הבדיקה. כך מובטחות תוצאות עקביות בין הרצות של הבדיקה.
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 צריכה להקפיץ הודעת שגיאה (throw) כדי שהבדיקה תוכל לוודא שהפונקציה 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 יכול לעזור בניהול גרפים מורכבים יותר של תלות.
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;
}
}
);
}
}
אחרי שמגדירים את מבנה הבדיקה, השלב הבא הוא לוודא שהנתונים שמוחזרים מהטמעת Pager נכונים. בדיקה אחת צריכה לוודא שאובייקט Pager טוען את נתוני ברירת המחדל כשהדף נטען בפעם הראשונה, ובדיקה אחרת צריכה לוודא שאובייקט Pager טוען נתונים נוספים בצורה נכונה על סמך קלט של משתמשים.
בדוגמה הבאה, הבדיקה מוודאת שהאובייקט Pager מעדכן את RecyclerView.Adapter עם מספר הפריטים הנכון שמוחזר מה-API כשהמשתמש מזין שם של קהילת Reddit אחרת לחיפוש.
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());
});
}
בדיקות עם מכשור צריכות לוודא שהנתונים מוצגים בצורה נכונה בממשק המשתמש. אפשר לעשות את זה על ידי בדיקה שמספר הפריטים הנכון קיים ב-RecyclerView.Adapter, או על ידי מעבר על תצוגות השורות הנפרדות ובדיקה שהנתונים בפורמט הנכון.