پیاده‌سازی صفحه‌بندی خود را آزمایش کنید (Views)

مفاهیم و پیاده‌سازی Jetpack Compose

پیاده‌سازی کتابخانه Paging در برنامه شما باید با یک استراتژی تست قوی همراه باشد. شما باید اجزای بارگذاری داده مانند PagingSource و RemoteMediator را آزمایش کنید تا مطمئن شوید که آنها مطابق انتظار کار می‌کنند. همچنین باید تست‌های سرتاسری بنویسید تا تأیید کنید که تمام اجزای پیاده‌سازی Paging شما به درستی و بدون عوارض جانبی غیرمنتظره با هم کار می‌کنند.

این راهنما نحوه‌ی تست کتابخانه‌ی Paging در لایه‌ی داده‌ی برنامه‌ی شما و همچنین نحوه‌ی نوشتن تست‌های سرتاسری برای کل پیاده‌سازی Paging را توضیح می‌دهد.

تست‌های لایه داده

برای کامپوننت‌های موجود در لایه داده خود، تست‌های واحد بنویسید تا مطمئن شوید که داده‌ها را به طور مناسب از منابع داده شما بارگذاری می‌کنند. نسخه‌های جعلی از وابستگی‌ها را ارائه دهید تا تأیید کنید که کامپوننت‌های مورد آزمایش به طور جداگانه به درستی کار می‌کنند. یکی از کامپوننت‌هایی که باید در لایه مخزن آزمایش کنید، RemoteMediator است.

تست‌های RemoteMediator

هدف از تست‌های واحد RemoteMediator ، تأیید این است که تابع load() مقدار صحیح MediatorResult را برمی‌گرداند. تست‌های مربوط به عوارض جانبی، مانند درج داده‌ها در پایگاه داده، برای تست‌های یکپارچه‌سازی مناسب‌تر هستند.

اولین قدم این است که مشخص کنید پیاده‌سازی RemoteMediator شما به چه وابستگی‌هایی نیاز دارد. مثال زیر یک پیاده‌سازی RemoteMediator را نشان می‌دهد که به یک پایگاه داده Room، یک رابط Retrofit و یک رشته جستجو نیاز دارد:

جاوا (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;
      ...
    }
  }

جاوا (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» نشان داده شده است، ارائه دهید. ارائه یک نسخه آزمایشی (mock) از پایگاه داده Room بسیار پیچیده است، بنابراین ارائه یک پیاده‌سازی درون حافظه‌ای از پایگاه داده به جای یک نسخه آزمایشی کامل می‌تواند آسان‌تر باشد. از آنجا که ایجاد یک پایگاه داده Room به یک شیء Context نیاز دارد، باید این تست RemoteMediator را در دایرکتوری androidTest قرار دهید و آن را با اجراکننده تست AndroidJUnit4 اجرا کنید تا به یک زمینه برنامه آزمایشی دسترسی داشته باشد. برای اطلاعات بیشتر در مورد تست‌های instrumented، به Build instrumented unit tests مراجعه کنید.

توابع tear-down را تعریف کنید تا اطمینان حاصل شود که حالت بین توابع تست نشت نمی‌کند. این امر نتایج ثابتی را بین اجراهای تست تضمین می‌کند.

جاوا (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();
  }
}

جاوا (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 را آزمایش کنید.

جاوا (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);
}

جاوا (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 پاک می‌کنید، به طور پیش‌فرض نتیجه‌ی خالی برمی‌گرداند.

جاوا (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);
}

جاوا (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 را برمی‌گرداند.

جاوا (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);
}

جاوا (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 ساختگی (mock API) برای جلوگیری از استفاده از شبکه در تست‌ها استفاده می‌کند. API ساختگی طوری پیکربندی شده است که مجموعه‌ای سازگار از داده‌های تست را برگرداند و در نتیجه تست‌های تکرارپذیر ایجاد کند. بر اساس عملکرد هر وابستگی، میزان سازگاری خروجی آن و میزان وفاداری مورد نیاز از تست‌های خود، تصمیم بگیرید که کدام وابستگی‌ها را برای پیاده‌سازی‌های ساختگی جایگزین کنید.

کد خود را به گونه‌ای بنویسید که به راحتی بتوانید نسخه‌های آزمایشی وابستگی‌های خود را جایگزین کنید. مثال زیر از یک پیاده‌سازی اولیه‌ی مکان‌یاب سرویس برای ارائه و تغییر وابستگی‌ها در صورت نیاز استفاده می‌کند. در برنامه‌های بزرگ‌تر، استفاده از یک کتابخانه‌ی تزریق وابستگی مانند Hilt می‌تواند به مدیریت نمودارهای وابستگی پیچیده‌تر کمک کند.

کاتلین

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
      }
    )
  }
}

جاوا (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;
        }
      }
    );
  }
}

جاوا (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، هنگامی که کاربر یک subreddit متفاوت را برای جستجو وارد می‌کند، به‌روزرسانی می‌کند.

کاتلین

@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)
  }
}

جاوا (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());
  });
}

جاوا (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 انجام دهید، یا با پیمایش در نماهای سطری مجزا و تأیید اینکه داده‌ها به درستی قالب‌بندی شده‌اند.