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

مفاهيم وتنفيذ 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 من مقالة اختبار عملية تنفيذ Paging. إنّ تقديم إصدار وهمي من قاعدة بيانات 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 بعد كل عملية تشغيل للاختبار، ستعرض 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، أو من خلال تكرار عرض الصفوف الفردية والتأكّد من أنّ البيانات منسّقة بشكلٍ صحيح.