Menguji implementasi Paging

Menerapkan library Paging di aplikasi Anda harus dipasangkan dengan strategi pengujian yang kuat. Anda harus menguji komponen pemuatan data, seperti PagingSource dan RemoteMediator untuk memastikan keduanya berfungsi seperti yang diharapkan. Anda juga harus menulis pengujian menyeluruh untuk memverifikasi bahwa semua komponen dalam implementasi Paging berfungsi dengan benar bersama-sama tanpa efek samping yang tidak terduga.

Panduan ini menjelaskan cara menguji library Paging di lapisan arsitektur yang berbeda pada aplikasi Anda, serta cara menulis pengujian menyeluruh untuk seluruh implementasi Paging.

Pengujian lapisan UI

Data yang diambil dengan library Paging digunakan di UI sebagai Flow<PagingData<Value>>. Untuk menulis pengujian guna memverifikasi data di UI seperti yang Anda harapkan, sertakan dependensi paging-testing. Dependensi ini berisi ekstensi asSnapshot() pada Flow<PagingData<Value>>. Hal ini memungkinkan Anda menyatakan bahwa data yang di-page berisi elemen yang diharapkan dan menggunakan List<Value> standar. Hal ini diilustrasikan dalam cuplikan berikut:

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(
    coroutineScope = this
  ) {
    // Each operation inside the lambda waits for the data to settle before continuing
    scrollTo(index = 50)

    // While you can’t view the items within the asSnapshot call,
    // you can continuously scroll in a direction while some condition is true
    // i.e., in this case until you hit the first header.
    appendScrollWhile {  item: String -> item != "Header 1" }
  }

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

Menguji transformasi

Anda juga harus menulis pengujian unit yang mencakup transformasi apa pun yang diterapkan ke aliran PagingData. Untuk menulis pengujian ini:

  • Gunakan ekstensi asPagingSourceFactory di Flow<List<Value>>.
  • Gunakan PagingSourceFactory yang ditampilkan dalam palsu untuk Repository.
  • Teruskan Repository tersebut ke ViewModel Anda.

ViewModel kemudian dapat diuji seperti yang dibahas di bagian sebelumnya. Pertimbangkan ViewModel berikut:

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

Untuk menguji transformasi di MyViewModel, sediakan instance MyRepository palsu yang didelegasikan ke Flow yang mewakili data yang akan diubah seperti yang ditunjukkan dalam cuplikan berikut:

class FakeMyRepository(
  testScope: CoroutineScope
): MyRepository {
    private val itemsFlow = flowOf((0..100).map(Any::toString))

    private val pagingSourceFactory = itemsFlow.asPagingSourceFactory(
      coroutineScope = testScope
    )

    val pagingSource = pagingSourceFactory()
}

Kemudian, Anda dapat menulis pengujian untuk logika pemisah seperti dalam cuplikan berikut:

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(coroutineScope = this) {}

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

Pengujian lapisan data

Tulis pengujian unit untuk komponen dalam lapisan data Anda guna memastikan bahwa unit memuat data dari sumber data Anda dengan tepat. Berikan versi tiruan dependensi untuk memverifikasi bahwa komponen yang sedang diuji berfungsi dengan benar secara terpisah. Komponen utama yang perlu Anda uji di lapisan repositori adalah PagingSource dan RemoteMediator. Contoh di bagian berikut ini akan didasarkan pada Contoh Paging dengan Jaringan.

Pengujian PagingSource

Pengujian unit untuk class PagingSource melibatkan penyiapan instance PagingSource dan memverifikasi bahwa fungsi load() menampilkan data yang di-page dengan benar berdasarkan argumen LoadParams. Berikan data tiruan ke konstruktor PagingSource, sehingga Anda memiliki kontrol atas data dalam pengujian Anda. Anda dapat meneruskan nilai default untuk jenis data dasar tetapi Anda harus meneruskan versi tiruan untuk objek lain, seperti database atau implementasi API. Tindakan ini memberi Anda kontrol penuh atas output sumber data palsu saat PagingSource yang sedang diuji berinteraksi dengan sumber data tersebut.

Parameter konstruktor untuk implementasi PagingSource spesifik Anda menentukan jenis data pengujian yang harus dilewati. Dalam contoh berikut, implementasi PagingSource memerlukan objek RedditApi serta String untuk nama subreddit. Anda dapat meneruskan nilai default untuk parameter String, tetapi untuk parameter RedditApi, Anda harus membuat implementasi tiruan untuk pengujian.

Kotlin

class ItemKeyedSubredditPagingSource(
  private val redditApi: RedditApi,
  private val subredditName: String
) : PagingSource<String, RedditPost>() {
  ...
}

Java

public class ItemKeyedSubredditPagingSource
  extends RxPagingSource<String, RedditPost> {

  @NonNull
  private RedditApi redditApi;
  @NonNull
  private String subredditName;

  ItemKeyedSubredditPagingSource(
    @NotNull RedditApi redditApi,
    @NotNull String subredditName
  ) {
    this.redditApi = redditApi;
    this.subredditName = subredditName;
    ...
  }
}

Java

public class ItemKeyedSubredditPagingSource
  extends ListenableFuturePagingSource<String, RedditPost> {

  @NonNull
  private RedditApi redditApi;
  @NonNull
  private String subredditName;
  @NonNull
  private Executor bgExecutor;

  public ItemKeyedSubredditPagingSource(
    @NotNull RedditApi redditApi,
    @NotNull String subredditName,
    @NonNull Executor bgExecutor
  ) {
    this.redditApi = redditApi;
    this.subredditName = subredditName;
    this.bgExecutor = bgExecutor;
    ...
  }
}

Dalam contoh ini, parameter RedditApi adalah antarmuka Retrofit yang menentukan permintaan server serta class respons. Versi tiruan dapat menerapkan antarmuka, mengganti fungsi yang diperlukan, dan menyediakan metode yang mudah untuk mengonfigurasi reaksi objek tiruan dalam pengujian.

Setelah objek tiruan tersedia, siapkan dependensi dan inisialisasi objek PagingSource dalam pengujian. Contoh berikut menunjukkan inisialisasi objek MockRedditApi dengan daftar postingan pengujian:

Kotlin

@OptIn(ExperimentalCoroutinesApi::class)
class SubredditPagingSourceTest {
  private val postFactory = PostFactory()
  private val mockPosts = listOf(
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT)
  )
  private val mockApi = MockRedditApi().apply {
    mockPosts.forEach { post -> addPost(post) }
  }

  @Test
  fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
    val pagingSource = ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT)
  }
}

Java

public class SubredditPagingSourceTest {
  static PostFactory postFactory = new PostFactory();
  static List<RedditPost> mockPosts = new ArrayList<>();
  static MockRedditApi mockApi = new MockRedditApi();

  static {
    for (int i=0; i<3; i++) {
      mockPosts.add(post);
      mockApi.addPost(post);
    }
  }

  @After tearDown() {
    // run.
    mockApi.setFailureMsg(null);
  }

  @Test loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() { ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT);
  }
} RedditPost post=postFactory.createRedditPost(DEFAULT_SUBREDDIT); public void Clear the failure message after each test public void throws InterruptedException ItemKeyedSubredditPagingSource pagingSource=new>

Java

public class SubredditPagingSourceTest {
  static PostFactory postFactory = new PostFactory();
  static List<RedditPost> mockPosts = new ArrayList<>();
  static MockRedditApi mockApi = new MockRedditApi();

  static {
    for (int i=0; i<3; i++) {
      mockPosts.add(post);
      mockApi.addPost(post);
    }
  }

  @After tearDown() {
    // run.
    mockApi.setFailureMsg(null);
  }

  @Test loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() ExecutionException, { ItemKeyedSubredditPagingSource(
        mockApi,
        DEFAULT_SUBREDDIT, CurrentThreadExecutor()
      );

  }
} RedditPost post=postFactory.createRedditPost(DEFAULT_SUBREDDIT); public void Clear the failure message after each test public void throws InterruptedException ItemKeyedSubredditPagingSource pagingSource=new new>

Langkah berikutnya adalah menguji objek PagingSource. Metode load() adalah fokus utama pengujian. Contoh berikut menunjukkan pernyataan yang memverifikasi bahwa metode load() menampilkan data yang benar, kunci sebelumnya, dan kunci berikutnya untuk parameter LoadParams tertentu:

Kotlin

@Test
// Since load is a suspend function, runTest is used to ensure that it
// runs on the test thread.
fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
  val pagingSource = ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT)
  assertEquals(
    expected = Page(
      data = listOf(mockPosts[0], mockPosts[1]),
      prevKey = mockPosts[0].name,
      nextKey = mockPosts[1].name
    ),
    actual = pagingSource.load(
      Refresh(
        key = null,
        loadSize = 2,
        placeholdersEnabled = false
      )
    ),
  )
}

Java

@Test
public void loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData()
  throws InterruptedException {

  ItemKeyedSubredditPagingSource pagingSource =
    new ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT);

  LoadParams.Refresh<String> refreshRequest =
    new LoadParams.Refresh<>(null, 2, false);

  pagingSource.loadSingle(refreshRequest)
    .test()
    .await()
    .assertValueCount(1)
    .assertValue(new LoadResult.Page<>(
      mockPosts.subList(0, 2),
      mockPosts.get(0).getName(),
      mockPosts.get(1).getName()
    ));
}

Java

@Test
public void loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData()
  throws ExecutionException, InterruptedException {

  ItemKeyedSubredditPagingSource pagingSource = new ItemKeyedSubredditPagingSource(
    mockApi,
    DEFAULT_SUBREDDIT,
    new CurrentThreadExecutor()
  );

  PagingSource.LoadParams.Refresh<String> refreshRequest =
    new PagingSource.LoadParams.Refresh<>(null, 2, false);

  PagingSource.LoadResult<String, RedditPost> result =
    pagingSource.loadFuture(refreshRequest).get();

  PagingSource.LoadResult<String, RedditPost> expected =
    new PagingSource.LoadResult.Page<>(
      mockPosts.subList(0, 2),
      mockPosts.get(0).getName(),
      mockPosts.get(1).getName()
    );

  assertThat(result, equalTo(expected));
}

Contoh sebelumnya menunjukkan pengujian PagingSource yang menggunakan paging dengan kunci item. Jika sumber data yang Anda gunakan menggunakan kunci halaman, maka pengujian PagingSource akan berbeda. Perbedaan utamanya adalah kunci sebelumnya dan berikutnya yang diharapkan dari metode load().

Kotlin

@Test
fun loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() = runTest {
  val pagingSource = PageKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT)
  assertEquals(
    expected = Page(
      data = listOf(mockPosts[0], mockPosts[1]),
      prevKey = null,
      nextKey = mockPosts[1].id
    ),
    actual = pagingSource.load(
      Refresh(
        key = null,
        loadSize = 2,
        placeholdersEnabled = false
      )
    ),
  )
}

Java

@Test
public void loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData()
  throws InterruptedException {

  PageKeyedSubredditPagingSource pagingSource =
    new PageKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT);

  LoadParams.Refresh<String> refreshRequest =
    new LoadParams.Refresh<>(null, 2, false);

  pagingSource.loadSingle(refreshRequest)
    .test()
    .await()
    .assertValueCount(1)
    .assertValue(new LoadResult.Page<>(
      mockPosts.subList(0, 2),
      null,
      mockPosts.get(1).getId()
    ));
}

Java

@Test
public void loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData()
  throws InterruptedException, ExecutionException {

  PageKeyedSubredditPagingSource pagingSource =
    new PageKeyedSubredditPagingSource(
      mockApi,
      DEFAULT_SUBREDDIT,
      new CurrentThreadExecutor()
    );

  PagingSource.LoadParams.Refresh<String> refreshRequest =
    new PagingSource.LoadParams.Refresh<>(null, 2, false);

  PagingSource.LoadResult<String, RedditPost> result =
    pagingSource.loadFuture(refreshRequest).get();

  PagingSource.LoadResult<String, RedditPost> expected =
    new PagingSource.LoadResult.Page<>(
      mockPosts.subList(0, 2),
      null,
      mockPosts.get(1).getId()
    );

  assertThat(result, equalTo(expected));
}

Pengujian RemoteMediator

Sasaran pengujian unit RemoteMediator adalah untuk memverifikasi bahwa fungsi load() menampilkan MediatorResult yang benar. Pengujian efek samping, seperti data yang dimasukkan ke dalam database, lebih cocok untuk pengujian integrasi.

Langkah pertama adalah menentukan dependensi yang diperlukan implementasi RemoteMediator Anda. Contoh berikut menunjukkan implementasi RemoteMediator yang memerlukan database Room, antarmuka Retrofit, dan string penelusuran:

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;
    ...
  }
}

Anda dapat memberikan antarmuka Retrofit dan string penelusuran seperti yang ditunjukkan di bagian pengujian PagingSource. Menyediakan versi tiruan database Room sangat diperlukan, sehingga dapat lebih mudah untuk menyediakan implementasi dalam memori dari database daripada versi tiruan lengkap. Karena membuat database Room memerlukan objek Context, Anda harus menempatkan pengujian RemoteMediator ini di direktori androidTest dan menjalankannya dengan runner pengujian AndroidJUnit4 sehingga memiliki akses ke konteks aplikasi pengujian. Untuk informasi selengkapnya tentang pengujian berinstrumen, lihat Membuat pengujian unit berinstrumen.

Tentukan fungsi penghapusan untuk memastikan bahwa status tidak bocor di antara fungsi pengujian. Hal ini untuk memastikan hasil yang konsisten di antara pengujian.

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

Langkah berikutnya adalah menguji fungsi load(). Dalam contoh ini, ada tiga kasus yang harus diuji:

  • Kasus pertama adalah saat mockApi menampilkan data yang valid. Fungsi load() akan menampilkan MediatorResult.Success, dan properti endOfPaginationReached harus false.
  • Kasus kedua adalah saat mockApi mengembalikan respons yang berhasil, tetapi data yang ditampilkan kosong. Fungsi load() harus menampilkan MediatorResult.Success, dan properti endOfPaginationReached harus true.
  • Kasus ketiga adalah saat mockApi memunculkan pengecualian sewaktu mengambil data. Fungsi load() harus menampilkan MediatorResult.Error.

Ikuti langkah-langkah berikut untuk menguji kasus pertama:

  1. Siapkan mockApi dengan data postingan yang akan ditampilkan.
  2. Inisialisasi objek RemoteMediator.
  3. Uji fungsi 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());
}

Pengujian kedua memerlukan mockApi untuk menampilkan hasil kosong. Karena Anda menghapus data dari mockApi setelah setiap pengujian dijalankan, data akan menampilkan hasil kosong secara default.

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

Pengujian terakhir memerlukan mockApi untuk menampilkan pengecualian sehingga pengujian dapat memverifikasi bahwa fungsi load() menampilkan MediatorResult.Error dengan benar.

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

Pengujian menyeluruh

Pengujian unit memberikan jaminan bahwa setiap komponen Paging berfungsi secara terpisah, tetapi pengujian menyeluruh memberikan keyakinan lebih bahwa aplikasi berfungsi secara keseluruhan. Pengujian ini masih memerlukan beberapa dependensi tiruan, tetapi umumnya mencakup sebagian besar kode aplikasi Anda.

Contoh di bagian ini menggunakan dependensi tiruan API untuk menghindari penggunaan jaringan dalam pengujian. API tiruan dikonfigurasi untuk mengembalikan kumpulan data pengujian yang konsisten, sehingga menghasilkan pengujian berulang. Tentukan dependensi yang akan ditukar dengan implementasi tiruan berdasarkan hal yang dilakukan setiap dependensi, seberapa konsisten outputnya, dan seberapa banyak fidelitas yang Anda butuhkan dari pengujian.

Tulis kode Anda dengan cara yang memudahkan Anda menukar versi tiruan dependensi Anda. Contoh berikut menggunakan implementasi pencari lokasi layanan dasar untuk menyediakan dan mengubah dependensi sesuai kebutuhan. Pada aplikasi yang lebih besar, penggunaan library injeksi dependensi seperti Hilt dapat membantu mengelola grafik dependensi yang lebih kompleks.

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

Setelah Anda menyiapkan struktur pengujian, langkah berikutnya adalah memverifikasi bahwa data yang ditampilkan oleh implementasi Pager sudah benar. Satu pengujian harus memastikan bahwa objek Pager memuat data default saat halaman pertama dimuat, dan pengujian lainnya harus memverifikasi bahwa objek Pager memuat data tambahan dengan benar berdasarkan input pengguna. Pada contoh berikut, pengujian memverifikasi bahwa objek Pager memperbarui RecyclerView.Adapter dengan jumlah item yang benar yang dikembalikan dari API saat pengguna memasukkan subreddit yang berbeda ke penelusuran.

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

Pengujian berinstrumen harus memverifikasi bahwa data ditampilkan dengan benar di UI. Lakukan ini dengan memverifikasi bahwa jumlah item yang benar ada di RecyclerView.Adapter, atau dengan melakukan iterasi melalui tampilan baris masing-masing dan memverifikasi bahwa data diformat dengan benar.