اختبار عملية تنفيذ ميزة "التصفُّح" (طُرق العرض)

المفاهيم والتنفيذ في 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.

اتّبِع الخطوات التالية لاختبار الحالة الأولى:

  1. اضبط mockApi باستخدام بيانات المشاركة التي تريد عرضها.
  2. ابدأ العنصر RemoteMediator.
  3. اختبِر الدالة 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 الفردية تعمل بشكل منفصل، ولكنّ الاختبارات الشاملة توفّر ثقة أكبر بأنّ التطبيق يعمل بشكل كامل. ستظل هذه الاختبارات بحاجة إلى بعض التبعيات الوهمية، ولكنها ستشمل بشكل عام معظم رموز تطبيقك.

يستخدم المثال الوارد في هذا القسم تبعية واجهة برمجة تطبيقات وهمية لتجنُّب استخدام الشبكة في الاختبارات. تم ضبط واجهة برمجة التطبيقات الوهمية لعرض مجموعة متسقة من بيانات الاختبار، ما يؤدي إلى اختبارات قابلة للتكرار. حدِّد التبعيات التي تريد استبدالها بتنفيذات وهمية استنادًا إلى وظيفة كل تبعية ومدى اتساق الناتج الذي تعرضه ومدى الدقة التي تحتاج إليها في اختباراتك.

اكتب الرمز البرمجي بطريقة تتيح لك استبدال إصدارات وهمية من التبعيات بسهولة. يستخدم المثال التالي تنفيذًا أساسيًا لميزة &quot;محدد موقع الخدمة&quot; لتوفير الاعتماديات وتغييرها حسب الحاجة. في التطبيقات الأكبر حجمًا، يمكن أن يساعد استخدام مكتبة لتوفير التبعية مثل 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، أو من خلال تكرار عروض الصفوف الفردية والتأكّد من أنّ البيانات منسَّقة بشكل صحيح.