ทดสอบการใช้งานการแบ่งหน้า (มุมมอง)

แนวคิดและการติดตั้งใช้งาน Jetpack Compose

การใช้ไลบรารีการแบ่งหน้าในแอปควรควบคู่ไปกับ กลยุทธ์การทดสอบที่มีประสิทธิภาพ คุณควรทดสอบคอมโพเนนต์การโหลดข้อมูล เช่น PagingSource และ RemoteMediator เพื่อให้แน่ใจว่าคอมโพเนนต์ทำงานได้ตามที่คาดไว้ นอกจากนี้ คุณควรเขียนการทดสอบตั้งแต่ต้นจนจบเพื่อยืนยันว่าคอมโพเนนต์ทั้งหมดในการใช้งานการแบ่งหน้าทำงานร่วมกันได้อย่างถูกต้องโดยไม่มีผลข้างเคียงที่ไม่คาดคิด

คู่มือนี้อธิบายวิธีการทดสอบไลบรารีการแบ่งหน้าในชั้นข้อมูลของแอป รวมถึงวิธีเขียนการทดสอบตั้งแต่ต้นจนจบสําหรับการติดตั้งใช้งานการแบ่งหน้าทั้งหมด

การทดสอบชั้นข้อมูล

เขียนการทดสอบหน่วยสำหรับคอมโพเนนต์ในชั้นข้อมูลเพื่อให้มั่นใจว่าคอมโพเนนต์จะโหลด ข้อมูลจากแหล่งข้อมูลอย่างเหมาะสม ระบุเวอร์ชันปลอมของทรัพยากร Dependency เพื่อยืนยันว่าคอมโพเนนต์ที่กำลังทดสอบทำงานได้อย่างถูกต้องในการแยก คอมโพเนนต์อย่างหนึ่งที่คุณต้องทดสอบในเลเยอร์ที่เก็บ คือ RemoteMediator

การทดสอบ RemoteMediator รายการ

เป้าหมายของRemoteMediatorการทดสอบหน่วยคือการยืนยันว่าload() ฟังก์ชันส่งคืน MediatorResult ที่ถูกต้อง การทดสอบผลข้างเคียง เช่น การแทรกข้อมูลลงในฐานข้อมูล เหมาะสำหรับการทดสอบการผสานรวมมากกว่า

ขั้นตอนแรกคือการพิจารณาว่าการติดตั้งใช้งานRemoteMediator ของคุณต้องมีทรัพยากร Dependency ใดบ้าง ตัวอย่างต่อไปนี้แสดงการติดตั้งใช้งาน 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 ของทดสอบการติดตั้งใช้งานการแบ่งหน้า การจัดเตรียมฐานข้อมูล Room เวอร์ชันจำลองเป็นเรื่องที่ซับซ้อนมาก ดังนั้นการจัดเตรียมการใช้งานในหน่วยความจำของฐานข้อมูลแทนเวอร์ชันจำลองแบบเต็มจึงอาจง่ายกว่า เนื่องจากการสร้างฐานข้อมูล Room ต้องใช้Context ออบเจ็กต์ คุณจึงต้องวางการทดสอบ RemoteMediator นี้ไว้ในไดเรกทอรี androidTest และเรียกใช้ด้วยโปรแกรมเรียกใช้การทดสอบ AndroidJUnit4 เพื่อให้มีการเข้าถึงบริบทแอปพลิเคชันทดสอบ ดูข้อมูลเพิ่มเติมเกี่ยวกับการทดสอบแบบมีเครื่องควบคุมได้ที่ สร้างการทดสอบ 1 หน่วยแบบมีเครื่องควบคุม

กำหนดฟังก์ชันการล้างข้อมูลเพื่อให้แน่ใจว่าสถานะจะไม่รั่วไหลระหว่างฟังก์ชันการทดสอบ ซึ่งจะช่วยให้ผลลัพธ์ระหว่างการทดสอบมีความสอดคล้องกัน

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()ฟังก์ชัน ในตัวอย่างนี้ มีกรณีที่ต้องทดสอบ 3 กรณี ดังนี้

  • กรณีแรกคือเมื่อ mockApi แสดงข้อมูลที่ถูกต้อง load() ฟังก์ชัน ควรแสดงผล MediatorResult.Success และพร็อพเพอร์ตี้ endOfPaginationReached ควรเป็น false
  • กรณีที่ 2 คือเมื่อ mockApi แสดงการตอบกลับที่สำเร็จ แต่ข้อมูลที่ส่งคืนว่างเปล่า ฟังก์ชัน load ควรแสดงผล MediatorResult.Success และพร็อพเพอร์ตี้ endOfPaginationReached ควรเป็น true
  • กรณีที่ 3 คือเมื่อ 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());
}

การทดสอบครั้งที่ 2 กำหนดให้ 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));
}

การทดสอบตั้งแต่ต้นจนจบ

การทดสอบหน่วยช่วยให้มั่นใจได้ว่าคอมโพเนนต์การแบ่งหน้าแต่ละรายการทำงานแยกกัน แต่การทดสอบแบบครบวงจรจะช่วยให้มั่นใจได้มากขึ้นว่าแอปพลิเคชันทำงานได้โดยรวม การทดสอบเหล่านี้ยังคงต้องใช้การจำลองการอ้างอิง แต่โดยทั่วไปแล้วจะครอบคลุมโค้ดของแอปส่วนใหญ่

ตัวอย่างในส่วนนี้ใช้ทรัพยากร Dependency ของ API แบบจำลองเพื่อหลีกเลี่ยงการใช้ระบบเครือข่ายในการทดสอบ ระบบจะกำหนดค่า API จำลองให้แสดงชุดข้อมูลทดสอบที่สอดคล้องกัน ซึ่งจะทำให้การทดสอบทำซ้ำได้ ตัดสินใจว่าจะแทนที่การอ้างอิงใดด้วยการติดตั้งใช้งานจำลองโดยพิจารณาจากสิ่งที่แต่ละการอ้างอิงทำ ความสอดคล้องของเอาต์พุต และความเที่ยงตรงที่คุณต้องการจากการทดสอบ

เขียนโค้ดในลักษณะที่ช่วยให้คุณสลับเวอร์ชันจำลองของ การอ้างอิงได้อย่างง่ายดาย ตัวอย่างต่อไปนี้ใช้การติดตั้งใช้งานตัวระบุตำแหน่งบริการพื้นฐานเพื่อระบุและเปลี่ยนทรัพยากร Dependency ตามต้องการ ในแอปขนาดใหญ่ การใช้ไลบรารีการขึ้นต่อกัน เช่น 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 เมื่อผู้ใช้ป้อนซับเรดดิตอื่นเพื่อค้นหา

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

การทดสอบที่มีการวัดควรยืนยันว่าข้อมูลแสดงอย่างถูกต้องใน UI ทำได้โดยการยืนยันว่ามีจำนวนสินค้าที่ถูกต้องใน RecyclerView.Adapter หรือโดยการวนซ้ำผ่านมุมมองแถวแต่ละรายการและ ยืนยันว่าข้อมูลได้รับการจัดรูปแบบอย่างถูกต้อง