المفاهيم والتنفيذ في Jetpack Compose
يجب أن يقترن تنفيذ مكتبة Paging في تطبيقك باستراتيجية اختبار قوية. عليك اختبار مكوّنات تحميل البيانات، مثل
PagingSource وRemoteMediator، للتأكّد من أنّها تعمل على النحو المتوقّع. عليك أيضًا كتابة اختبارات شاملة للتأكّد من أنّ جميع المكوّنات في عملية تنفيذ نظام التقسيم إلى صفحات تعمل معًا بشكل صحيح بدون أي آثار جانبية غير متوقّعة.
يوضّح هذا الدليل كيفية اختبار مكتبة Paging في طبقة البيانات الخاصة بتطبيقك، بالإضافة إلى كيفية كتابة اختبارات شاملة لعملية تنفيذ مكتبة Paging بأكملها.
اختبارات طبقة البيانات
اكتب اختبارات الوحدات للمكوّنات في طبقة البيانات للتأكّد من أنّها تحمّل البيانات من مصادر البيانات بشكل سليم. قدِّم إصدارات وهمية من التبعيات للتأكّد من أنّ المكوّنات التي يتم اختبارها تعمل بشكل صحيح بشكل مستقل. أحد المكوّنات التي عليك اختبارها في طبقة المستودع
هو RemoteMediator.
اختبارات RemoteMediator
الهدف من RemoteMediator اختبارات الوحدات هو التحقّق من أنّ الدالة load()
تعرض MediatorResult الصحيح. تكون اختبارات الآثار الجانبية، مثل إدراج البيانات في قاعدة البيانات، أكثر ملاءمة لاختبارات الدمج.
الخطوة الأولى هي تحديد العناصر التابعة التي تحتاجها عملية التنفيذ RemoteMediator. يوضّح المثال التالي عملية تنفيذ تتطلّب قاعدة بيانات Room وواجهة Retrofit وسلسلة بحث:RemoteMediator
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باستخدام بيانات المشاركة التي تريد عرضها. - ابدأ العنصر
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));
}
اختبارات شاملة
توفّر اختبارات الوحدات ضمانًا بأنّ مكوّنات Paging الفردية تعمل بشكل منفصل، ولكنّ الاختبارات الشاملة توفّر ثقة أكبر بأنّ التطبيق يعمل بشكل كامل. ستظل هذه الاختبارات بحاجة إلى بعض التبعيات الوهمية، ولكنها ستشمل بشكل عام معظم رموز تطبيقك.
يستخدم المثال الوارد في هذا القسم تبعية واجهة برمجة تطبيقات وهمية لتجنُّب استخدام الشبكة في الاختبارات. تم ضبط واجهة برمجة التطبيقات الوهمية لعرض مجموعة متسقة من بيانات الاختبار، ما يؤدي إلى اختبارات قابلة للتكرار. حدِّد التبعيات التي تريد استبدالها بتنفيذات وهمية استنادًا إلى وظيفة كل تبعية ومدى اتساق الناتج الذي تعرضه ومدى الدقة التي تحتاج إليها في اختباراتك.
اكتب الرمز البرمجي بطريقة تتيح لك استبدال إصدارات وهمية من التبعيات بسهولة. يستخدم المثال التالي تنفيذًا أساسيًا لميزة "محدد موقع الخدمة" لتوفير الاعتماديات وتغييرها حسب الحاجة. في التطبيقات الأكبر حجمًا، يمكن أن يساعد استخدام مكتبة لتوفير التبعية مثل 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 بالعدد الصحيح من العناصر التي تم إرجاعها من واجهة برمجة التطبيقات عندما يدخل المستخدم منتدى فرعيًا مختلفًا للبحث.
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، أو من خلال تكرار عروض الصفوف الفردية والتأكّد من أنّ البيانات منسَّقة بشكل صحيح.