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

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

คู่มือนี้จะอธิบายวิธีทดสอบไลบรารีการแบ่งหน้าใน เลเยอร์ของสถาปัตยกรรมของแอปและวิธีเขียน การทดสอบแบบ end-to-end สำหรับการติดตั้งใช้งาน Paging ทั้งหมด

การทดสอบเลเยอร์ UI

ข้อมูลที่ดึงด้วยไลบรารีการสร้างหน้าจะใช้ใน UI เนื่องจาก Flow<PagingData<Value>> หากต้องการเขียนการทดสอบเพื่อยืนยันว่าข้อมูลใน UI เป็นไปตามที่คาดไว้ ให้ใส่ ทรัพยากร Dependency paging-testing มีส่วนขยาย asSnapshot() ใน Flow<PagingData<Value>> ทั้งนี้ มี API ในตัวรับสัญญาณ lambda ที่ให้คุณเลียนแบบการเลื่อนได้ การโต้ตอบ แสดง List<Value> มาตรฐานที่สร้างโดยการเลื่อน ที่จำลองการโต้ตอบขึ้น ซึ่งทำให้คุณสามารถยืนยันข้อมูล หน้าเว็บผ่าน ประกอบด้วยองค์ประกอบที่คาดไว้ซึ่งสร้างขึ้นจากการโต้ตอบเหล่านั้น มีภาพตัวอย่างดังต่อไปนี้

fun test_items_contain_one_to_ten() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll to the 50th item in the list. This will also suspend till
    // the prefetch requirement is met if there's one.
    // It also suspends until all loading is complete.
    scrollTo(index = 50)
  }

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected values
  assertEquals(
    expected = (0..50).map(Int::toString),
    actual = itemsSnapshot
  )
}

หรือคุณสามารถเลื่อนจนกว่าจะเป็นไปตามเงื่อนไขที่กำหนดตามที่แสดงใน ตัวอย่างด้านล่าง


fun test_footer_is_visible() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll till the footer is visible
    appendScrollWhile {  item: String -> item != "Footer" }
  }

การทดสอบการเปลี่ยนรูปแบบ

คุณควรเขียนการทดสอบ 1 หน่วยที่ครอบคลุมการเปลี่ยนรูปแบบที่คุณใช้ด้วย สตรีม PagingData ใช้ asPagingSourceFactory ส่วนขยาย ส่วนขยายนี้ใช้งานได้กับข้อมูลประเภทต่อไปนี้

  • List<Value>
  • Flow<List<Value>>

ตัวเลือกในการใช้ส่วนขยายจะขึ้นอยู่กับสิ่งที่คุณพยายามทดสอบ ใช้:

  • List<Value>.asPagingSourceFactory(): หากต้องการทดสอบแบบคงที่ เช่น map() และ insertSeparators() ในข้อมูล
  • Flow<List<Value>>.asPagingSourceFactory(): หากต้องการทดสอบวิธีการอัปเดต ลงในข้อมูล เช่น การเขียนไปยังแหล่งข้อมูลสนับสนุน จะส่งผลต่อการแบ่งหน้า ไปป์ไลน์

หากต้องการใช้ส่วนขยายเหล่านี้ ให้ใช้รูปแบบต่อไปนี้

  • สร้าง PagingSourceFactory โดยใช้ส่วนขยายที่เหมาะสมสำหรับ ความต้องการ
  • ใช้ PagingSourceFactory ที่ส่งคืนใน ปลอม สำหรับ Repository
  • ส่ง Repository ไปยัง ViewModel

จากนั้นคุณสามารถทดสอบ ViewModel ได้เหมือนอย่างที่อธิบายไว้ในส่วนก่อนหน้า ลองพิจารณา ViewModel ต่อไปนี้

class MyViewModel(
  myRepository: myRepository
) {
  val items = Pager(
    config: PagingConfig,
    initialKey = null,
    pagingSourceFactory = { myRepository.pagingSource() }
  )
  .flow
  .map { pagingData ->
    pagingData.insertSeparators<String, String> { before, _ ->
      when {
        // Add a dashed String separator if the prior item is a multiple of 10
        before.last() == '0' -> "---------"
        // Return null to avoid adding a separator between two items.
        else -> null
      }
  }
}

ในการทดสอบการเปลี่ยนรูปแบบใน MyViewModel ให้ระบุอินสแตนซ์ปลอมของ MyRepository ที่มอบสิทธิ์ให้กับ List แบบคงที่ซึ่งเป็นตัวแทนของข้อมูลที่จะ เปลี่ยนรูปแบบตามที่แสดงในตัวอย่างต่อไปนี้

class FakeMyRepository(): MyRepository {
    private val items = (0..100).map(Any::toString)

    private val pagingSourceFactory = items.asPagingSourceFactory()

    val pagingSource = pagingSourceFactory()
}

จากนั้นคุณสามารถเขียนการทดสอบสำหรับตรรกะตัวคั่นตามตัวอย่างในตัวอย่างต่อไปนี้

fun test_separators_are_added_every_10_items() = runTest {
  // Create your ViewModel
  val viewModel = MyViewModel(
    myRepository = FakeMyRepository()
  )
  // Get the Flow of PagingData from the ViewModel with the separator transformations applied
  val items: Flow<PagingData<String>> = viewModel.items
                  
  val snapshot: List<String> = items.asSnapshot()

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected separators.
}

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

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

การทดสอบ PagingSource

การทดสอบ 1 หน่วยสำหรับการติดตั้งใช้งาน PagingSource เกี่ยวข้องกับการตั้งค่า PagingSource อินสแตนซ์และการโหลดข้อมูลจากอินสแตนซ์ด้วย TestPager

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

หลังจากมีของปลอมแล้ว ให้ตั้งค่า Dependency และเริ่ม PagingSource ออบเจ็กต์ในการทดสอบ ตัวอย่างต่อไปนี้แสดง การเริ่มต้นออบเจ็กต์ FakeRedditApi ด้วยรายการโพสต์ทดสอบ และการทดสอบ อินสแตนซ์ RedditPagingSource:

class SubredditPagingSourceTest {
  private val mockPosts = listOf(
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT)
  )
  private val fakeApi = FakeRedditApi().apply {
    mockPosts.forEach { post -> addPost(post) }
  }

  @Test
  fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
    val pagingSource = RedditPagingSource(
      fakeApi,
      DEFAULT_SUBREDDIT
    )

    val pager = TestPager(CONFIG, pagingSource)

    val result = pager.refresh() as LoadResult.Page

    // Write assertions against the loaded data
    assertThat(result.data)
    .containsExactlyElementsIn(mockPosts)
    .inOrder()
  }
}

TestPager ยังให้คุณทําสิ่งต่อไปนี้ได้ด้วย

  • ทดสอบการโหลดติดต่อกันจาก PagingSource ของคุณ:
    @Test
    fun test_consecutive_loads() = runTest {

      val page = with(pager) {
        refresh()
        append()
        append()
      } as LoadResult.Page

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • สถานการณ์ข้อผิดพลาดการทดสอบใน PagingSource
    @Test
    fun refresh_returnError() {
        val pagingSource = RedditPagingSource(
          fakeApi,
          DEFAULT_SUBREDDIT
        )
        // Configure your fake to return errors
        fakeApi.setReturnsError()
        val pager = TestPager(CONFIG, source)

        runTest {
            source.errorNextLoad = true
            val result = pager.refresh()
            assertTrue(result is LoadResult.Error)

            val page = pager.getLastLoadedPage()
            assertThat(page).isNull()
        }
    }

การทดสอบ RemoteMediator

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

ขั้นตอนแรกคือการกำหนดทรัพยากร Dependency สำหรับ RemoteMediator ความต้องการในการติดตั้งใช้งาน ตัวอย่างต่อไปนี้แสดงRemoteMediator ซึ่งจำเป็นต้องมีฐานข้อมูลห้อง อินเทอร์เฟซ Retrofit และการค้นหา สตริง:

Kotlin

@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
  private val db: RedditDb,
  private val redditApi: RedditApi,
  private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
  ...
}

Java

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

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

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

Kotlin

@ExperimentalPagingApi
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class PageKeyedRemoteMediatorTest {
  private val postFactory = PostFactory()
  private val mockPosts = listOf(
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT)
  )
  private val mockApi = mockRedditApi()

  private val mockDb = RedditDb.create(
    ApplicationProvider.getApplicationContext(),
    useInMemory = true
  )

  @After
  fun tearDown() {
    mockDb.clearAllTables()
    // Clear out failure message to default to the successful response.
    mockApi.failureMsg = null
    // Clear out posts after each test run.
    mockApi.clearPosts()
  }
}

Java

@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

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

Kotlin

@Test
fun refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() = runTest {
  // Add mock results for the API to return.
  mockPosts.forEach { post -> mockApi.addPost(post) }
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState<Int, RedditPost>(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertFalse { (result as MediatorResult.Success).endOfPaginationReached }
}

Java

@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

@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 หลังจากทำการทดสอบแต่ละครั้ง ระบบจะแสดงผลค่าที่ว่างเปล่า ผลลัพธ์โดยค่าเริ่มต้น

Kotlin

@Test
fun refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() = runTest {
  // To test endOfPaginationReached, don't set up the mockApi to return post
  // data here.
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState<Int, RedditPost>(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertTrue { (result as MediatorResult.Success).endOfPaginationReached }
}

Java

@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

@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 อย่างถูกต้อง

Kotlin

@Test
fun refreshLoadReturnsErrorResultWhenErrorOccurs() = runTest {
  // Set up failure message to throw exception from the mock API.
  mockApi.failureMsg = "Throw test failure"
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState<Int, RedditPost>(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue {result is MediatorResult.Error }
}

Java

@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

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

การทดสอบแบบเอนด์ทูเอนด์

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

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

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

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

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 นั้นถูกต้อง การทดสอบ 1 รายการควรตรวจสอบให้แน่ใจว่า ออบเจ็กต์ Pager จะโหลดข้อมูลเริ่มต้นเมื่อโหลดหน้าเว็บครั้งแรก และโหลดอีกรายการหนึ่ง การทดสอบควรยืนยันว่าออบเจ็กต์ Pager โหลดข้อมูลเพิ่มเติมได้อย่างถูกต้องตาม ในข้อมูลจากผู้ใช้ ในตัวอย่างต่อไปนี้ การทดสอบจะยืนยันว่า Pager ออบเจ็กต์อัปเดต RecyclerView.Adapter ด้วยจำนวนรายการที่ถูกต้อง ที่แสดงผลจาก API เมื่อผู้ใช้ป้อน Subreddit อื่นเพื่อค้นหา

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

@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

@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หรือทำซ้ำผ่านมุมมองแต่ละแถวและ ตรวจสอบว่าข้อมูลอยู่ในรูปแบบที่ถูกต้องหรือไม่