Kiểm thử hoạt động triển khai Phân trang

Stay organized with collections Save and categorize content based on your preferences.

Việc triển khai thư viện Phân trang trong ứng dụng của bạn nên được kết hợp với một chiến lược kiểm thử mạnh mẽ. Bạn nên kiểm thử các thành phần tải dữ liệu, như PagingSourceRemoteMediator để đảm bảo các thành phần đó hoạt động như mong đợi. Bạn cũng nên viết mã kiểm thử toàn diện để xác minh rằng tất cả các thành phần trong quá trình triển khai Phân trang hoạt động đúng cách cùng nhau mà không có ảnh hưởng bất lợi ngoài dự kiến nào.

Hướng dẫn này giải thích cách kiểm thử riêng từng nguồn dữ liệu phân trang, cách kiểm thử các lần chuyển đổi mà bạn thực hiện trên dữ liệu đã phân trang và cách viết mã kiểm thử toàn diện cho toàn bộ quá trình triển khai Phân trang. Các ví dụ trong hướng dẫn này dựa trên phần Phân trang bằng mẫu mạng.

Kiểm thử lớp lưu trữ

Viết mã kiểm thử đơn vị cho các thành phần trong lớp lưu trữ của bạn để đảm bảo chúng tải dữ liệu từ các nguồn dữ liệu của bạn một cách thích hợp. Cung cấp các phiên bản mô phỏng của các phần phụ thuộc để xác minh rằng các thành phần kiểm thử hoạt động đúng cách khi tách biệt. Các thành phần chính mà bạn cần kiểm thử trong lớp lưu trữ là PagingSourceRemoteMediator.

Kiểm thử PagingSource

Các hoạt động kiểm thử đơn vị cho loại PagingSource của bạn liên quan đến việc thiết lập thực thể PagingSource và xác minh rằng hàm load() trả về dữ liệu đã phân trang chính xác dựa trên một đối số LoadParams. Cung cấp dữ liệu mô phỏng cho hàm dựng PagingSource để bạn có quyền kiểm soát dữ liệu trong các lần kiểm thử của mình. Bạn có thể chuyển một giá trị mặc định cho các loại dữ liệu gốc nhưng cần chuyển một phiên bản mô phỏng cho các đối tượng khác, chẳng hạn như cơ sở dữ liệu hoặc việc triển khai API. Điều này giúp bạn có toàn quyền kiểm soát kết quả của nguồn dữ liệu mô phỏng khi PagingSource trong quá trình kiểm thử tương tác với nguồn dữ liệu đó.

Các thông số hàm dựng cho hoạt động triển khai PagingSource cụ thể của bạn cho biết loại dữ liệu kiểm thử mà bạn cần chuyển. Trong ví dụ sau, việc triển khai PagingSource yêu cầu đối tượng RedditApi cũng như String cho tên chuyên mục. Bạn có thể chuyển một giá trị mặc định cho tham số String, nhưng đối với tham số RedditApi, bạn phải tạo một hoạt động triển khai mô phỏng cho kiểm thử.

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

Trong ví dụ này, tham số RedditApi là giao diện Retrofit xác định các yêu cầu của máy chủ cũng như các loại phản hồi. Phiên bản mô phỏng có thể triển khai giao diện, ghi đè mọi hàm bắt buộc và cung cấp các phương thức thuận tiện để định cấu hình cách đối tượng mô phỏng sẽ phản ứng trong các lần kiểm thử.

Sau khi có đối tượng mô phỏng, hãy thiết lập các phần phụ thuộc và khởi động đối tượng PagingSource trong kiểm thử. Ví dụ sau minh hoạ việc khởi chạy đối tượng MockRedditApi với một danh sách các bài đăng kiểm thử:

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>

Bước tiếp theo là kiểm thử đối tượng PagingSource. Phương thức load() là trọng tâm chính của các lần kiểm thử. Ví dụ sau đây minh hoạ cho câu nhận định xác minh rằng phương thức load() trả về dữ liệu chính xác, khoá trước và khoá tiếp theo cho một tham số LoadParams nhất định:

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

Ví dụ trước minh hoạ việc kiểm thử một PagingSource sử dụng cách phân trang theo mục. Thay vào đó, nếu nguồn dữ liệu bạn đang sử dụng phân tách theo trang, các hoạt động kiểm thử PagingSource sẽ khác. Điểm khác biệt chính là các khoá trước và tiếp theo được dự kiến từ phương thức 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));
}

Kiểm thử RemoteMediator

Mục tiêu của loại hình kiểm thử đơn vị RemoteMediator là nhằm xác minh rằng hàm load() trả về đúng MediatorResult. Các loại hình kiểm thử cho ảnh hưởng không mong muốn, chẳng hạn như dữ liệu được chèn vào cơ sở dữ liệu, là lựa chọn phù hợp hơn cho kiểm thử tích hợp.

Bước đầu tiên là xác định những phần phụ thuộc cần cho quá trình triển khai RemoteMediator của bạn. Ví dụ sau minh hoạ quá trình triển khai RemoteMediator yêu cầu cơ sở dữ liệu Phòng, giao diện Retrofit và chuỗi tìm kiếm:

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

Bạn có thể cung cấp giao diện Retrofit và chuỗi tìm kiếm như minh hoạ trong mục kiểm thử PagingSource. Việc cung cấp một phiên bản mô phỏng của cơ sở dữ liệu Phòng rất phù hợp. Vì vậy, việc cung cấp cách triển khai trong bộ nhớ của cơ sở dữ liệu có thể dễ dàng hơn là một phiên bản mô phỏng đầy đủ. Vì việc tạo cơ sở dữ liệu Phòng yêu cầu phải có đối tượng Context, bạn phải đặt loại hình kiểm thử RemoteMediator này vào thư mục androidTest và thực thi bằng đối tượng trình chạy kiểm thử AndroidJUnit4 để có quyền truy cập vào ngữ cảnh của ứng dụng kiểm thử. Để biết thêm thông tin về loại hình kiểm thử được đo lường, hãy xem phần Tạo loại hình kiểm thử đơn vị được đo lường.

Xác định các hàm phân giải để đảm bảo rằng trạng thái đó không bị rò rỉ giữa các hàm kiểm thử. Điều này đảm bảo kết quả nhất quán giữa các lần chạy kiểm thử.

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

Bước tiếp theo là kiểm thử hàm load(). Trong ví dụ này, có ba trường hợp cần kiểm thử:

  • Trường hợp đầu tiên là mockApi trả về dữ liệu hợp lệ. Hàm load() cần trả về MediatorResult.Success và thuộc tính endOfPaginationReached phải là false.
  • Trường hợp thứ hai là mockApi trả về một phản hồi thành công, nhưng dữ liệu trả về bị trống. Hàm load() cần trả về MediatorResult.Success và thuộc tính endOfPaginationReached phải là true.
  • Trường hợp thứ ba là mockApi tạo một ngoại lệ khi tìm nạp dữ liệu. Hàm load() cần trả về MediatorResult.Error.

Hãy thực hiện theo các bước sau để kiểm thử trường hợp đầu tiên:

  1. Thiết lập mockApi với dữ liệu bài đăng cần trả về.
  2. Khởi động đối tượng RemoteMediator.
  3. Kiểm tra hàm 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());
}

Trường hợp kiểm thử thứ hai yêu cầu mockApi trả về kết quả trống. Vì bạn xoá dữ liệu khỏi mockApi sau mỗi lần chạy kiểm thử nên kết quả trả về sẽ trống theo mặc định.

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

Trường hợp kiểm thử cuối cùng yêu cầu mockApi gửi một ngoại lệ để quá trình kiểm thử có thể xác minh rằng hàm load() trả về MediatorResult.Error chính xác.

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

Kiểm thử hoạt động chuyển đổi

Bạn cũng nên viết mã kiểm thử đơn vị để bao gồm mọi lượt chuyển đổi mà bạn áp dụng cho luồng PagingData. Nếu quá trình triển khai Phân trang thực hiện bất kỳ hoạt động lọc hay ánh xạ dữ liệu nào, bạn nên kiểm thử các lượt chuyển đổi này để đảm bảo rằng chúng hoạt động như mong đợi. Bạn cần sử dụng AsyncPagingDataDiffer trong các lần kiểm thử này vì bạn không thể truy cập trực tiếp vào kết quả của luồng PagingData.

Ví dụ sau minh hoạ một số lượt chuyển đổi cơ bản áp dụng cho một đối tượng PagingData nhằm mục đích kiểm thử:

Kotlin

fun PagingData<Int>.myHelperTransformFunction(): PagingData<Int> {
  return this.map { item ->
    item * item
  }.filter { item ->
    item % 2 == 0
  }
}

Java

public PagingData<Integer> myHelperTransformFunction(PagingData<Integer> pagingData) {
  PagingData<Integer> mappedData = PagingDataTransforms.map(
    pagingData,
    new CurrentThreadExecutor(),
    ((item) -> item * item)
  );
  return PagingDataTransforms.filter(
    mappedData,
    new CurrentThreadExecutor(),
    ((item) -> item % 2 == 0)
  );
}

Java

public PagingData<Integer> myHelperTransformFunction(PagingData<Integer> pagingData) {
  PagingData<Integer> mappedData = PagingDataTransforms.map(
    pagingData,
    new CurrentThreadExecutor(),
    ((item) -> item * item)
  );
  return PagingDataTransforms.filter(
    mappedData,
    new CurrentThreadExecutor(),
    ((item) -> item % 2 == 0)
  );
}

Đối tượng AsyncPagingDataDiffer yêu cầu nhiều tham số, nhưng hầu hết các tham số đó có thể là các lần triển khai trống nhằm mục đích kiểm thử. Hai trong số các tham số này là một lần triển khai DiffUtil.ItemCallback và một lần triển khai ListUpdateCallback:

Kotlin

class NoopListCallback : ListUpdateCallback {
  override fun onChanged(position: Int, count: Int, payload: Any?) {}
  override fun onMoved(fromPosition: Int, toPosition: Int) {}
  override fun onInserted(position: Int, count: Int) {}
  override fun onRemoved(position: Int, count: Int) {}
}

class MyDiffCallback : DiffUtil.ItemCallback<Int>() {
  override fun areItemsTheSame(oldItem: Int, newItem: Int): Boolean {
    return oldItem == newItem
  }

  override fun areContentsTheSame(oldItem: Int, newItem: Int): Boolean {
    return oldItem == newItem
  }
}

Java

class NoopListCallback implements ListUpdateCallback {
  @Override
  public void onInserted(int position, int count) {}

  @Override
  public void onRemoved(int position, int count) {}

  @Override
  public void onMoved(int fromPosition, int toPosition) {}

  @Override
  public void onChanged(
    int position,
    int count,
    @Nullable Object payload
  ) { }
}

class MyDiffCallback extends DiffUtil.ItemCallback<Integer> {
  @Override
  public boolean areItemsTheSame(
    @NonNull Integer oldItem,
    @NonNull Integer newItem
  ) {
    return oldItem.equals(newItem);
  }

  @Override
  public boolean areContentsTheSame(
    @NonNull Integer oldItem,
    @NonNull Integer newItem
  ) {
    return oldItem.equals(newItem);
  }
}

Java

class NoopListCallback implements ListUpdateCallback {
  @Override
  public void onInserted(int position, int count) {}

  @Override
  public void onRemoved(int position, int count) {}

  @Override
  public void onMoved(int fromPosition, int toPosition) {}

  @Override
  public void onChanged(
    int position,
    int count,
    @Nullable Object payload
  ) { }
}

class MyDiffCallback extends DiffUtil.ItemCallback<Integer> {
  @Override
  public boolean areItemsTheSame(
    @NonNull Integer oldItem,
    @NonNull Integer newItem
  ) {
    return oldItem.equals(newItem);
  }

  @Override
  public boolean areContentsTheSame(
    @NonNull Integer oldItem,
    @NonNull Integer newItem
  ) {
    return oldItem.equals(newItem);
  }
}

Khi sử dụng các phần phụ thuộc này, bạn có thể viết mã kiểm thử hoạt động chuyển đổi. Những lần kiểm thử nên thực hiện các bước sau:

  1. Khởi động PagingData với dữ liệu kiểm thử.
  2. Chạy các lượt chuyển đổi trên PagingData.
  3. Chuyển dữ liệu đã chuyển đổi sang nơi khác.
  4. Sau khi phân tích cú pháp dữ liệu ở nơi khác, hãy mở ảnh chụp nhanh của kết quả đó và xác minh rằng dữ liệu là chính xác.

Ví dụ sau minh hoạ quá trình này:

Kotlin

@ExperimentalCoroutinesApi
class PagingDataTransformTest {
  private val testScope = TestScope()
  private val testDispatcher = StandardTestDispatcher(testScope.testScheduler)

  @Before
  fun setUp() {
    Dispatchers.setMain(testDispatcher)
  }

  @After
  fun tearDown() {
    Dispatchers.resetMain()
  }

  @Test
  fun differTransformsData() = testScope.runTest {
    val data = PagingData.from(listOf(1, 2, 3, 4)).myHelperTransformFunction()
    val differ = AsyncPagingDataDiffer(
      diffCallback = MyDiffCallback(),
      updateCallback = NoopListCallback(),
      workerDispatcher = Dispatchers.Main
    )

    // You don't need to use lifecycleScope.launch() if you're using
    // PagingData.from()
    differ.submitData(data)

    // Wait for transforms and the differ to process all updates.
    advanceUntilIdle()
    assertEquals(listOf(4, 16), differ.snapshot().items)
  }
}

Java

List<Integer> data = Arrays.asList(1, 2, 3, 4);
PagingData<Integer> pagingData = PagingData.from(data);
PagingData<Integer> transformedData = myHelperTransformFunction(pagingData);

Executor executor = new CurrentThreadExecutor();
CoroutineDispatcher dispatcher = ExecutorsKt.from(executor);

AsyncPagingDataDiffer<Integer> differ = new AsyncPagingDataDiffer<Integer>(
  new MyDiffCallback(),
  new NoopListCallback(),
  dispatcher,
  dispatcher
);

TestLifecycleOwner lifecycleOwner =
    new TestLifecycleOwner(Lifecycle.State.RESUMED, dispatcher);

differ.submitData(lifecycleOwner.getLifecycle(), transformedData);

// Wait for submitData() to present some data.
while (differ.getItemCount() == 0) {
  Thread.sleep(100);
}

assertEquals(Arrays.asList(4, 16), differ.snapshot().getItems());

Java

List<Integer> data = Arrays.asList(1, 2, 3, 4);
PagingData<Integer> pagingData = PagingData.from(data);
PagingData<Integer> transformedData = myHelperTransformFunction(pagingData);

Executor executor = new CurrentThreadExecutor();
CoroutineDispatcher dispatcher = ExecutorsKt.from(executor);

AsyncPagingDataDiffer<Integer> differ = new AsyncPagingDataDiffer<Integer>(
  new MyDiffCallback(),
  new NoopListCallback(),
  dispatcher,
  dispatcher
);

TestLifecycleOwner lifecycleOwner =
    new TestLifecycleOwner(Lifecycle.State.RESUMED, dispatcher);

differ.submitData(lifecycleOwner.getLifecycle(), transformedData);

// Wait for submitData() to present some data.
while (differ.getItemCount() == 0) {
  Thread.sleep(100);
}

assertEquals(Arrays.asList(4, 16), differ.snapshot().getItems());

Kiểm thử toàn diện

Loại hình kiểm thử đơn vị nhằm đảm bảo rằng các thành phần Phân trang riêng lẻ hoạt động tách biệt, nhưng loại hình kiểm thử toàn diện gia tăng khoảng tin cậy rằng ứng dụng hoạt động như một thể thống nhất. Các loại hình kiểm thử này vẫn cần một số phần phụ thuộc mô phỏng, nhưng thường sẽ có hầu hết mã ứng dụng của bạn.

Ví dụ trong mục này sử dụng phần phụ thuộc API mô phỏng để tránh sử dụng mạng trong quá trình kiểm thử. API mô phỏng được định cấu hình để trả về một tập hợp dữ liệu kiểm thử nhất quán, dẫn đến các lần kiểm thử lặp lại. Bạn có thể quyết định phần phụ thuộc nào cần hoán đổi cho các lần triển khai mô phỏng dựa trên chức năng của mỗi phần phụ thuộc, mức độ nhất quán của kết quả và mức độ trung thực mà bạn cần từ những lần kiểm thử.

Viết mã của bạn để cho phép bạn dễ dàng hoán đổi trong các phiên bản mô phỏng của các phần phụ thuộc. Ví dụ sau đây sử dụng một quy trình triển khai bộ định vị dịch vụ cơ bản để cung cấp và thay đổi các phần phụ thuộc (nếu cần). Trong các ứng dụng lớn hơn, việc sử dụng thư viện chèn phần phụ thuộc như Hilt có thể giúp quản lý các biểu đồ phần phụ thuộc phức tạp hơn.

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

Sau khi bạn thiết lập cấu trúc kiểm thử, bước tiếp theo là xác minh rằng dữ liệu mà hoạt động triển khai Pager trả về là chính xác. Một quy trình kiểm thử phải đảm bảo rằng đối tượng Pager tải dữ liệu mặc định khi trang tải lần đầu tiên và một quy trình kiểm thử khác cần xác minh rằng đối tượng Pager tải đúng dữ liệu bổ sung dựa trên hoạt động đầu vào của người dùng. Trong ví dụ sau, quy trình kiểm thử xác minh rằng đối tượng Pager cập nhật RecyclerView.Adapter với số lượng mục chính xác được trả về từ API khi người dùng nhập một chuyên mục khác để tìm kiếm.

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

Những lần kiểm thử được đo lường cần xác minh rằng dữ liệu hiển thị chính xác trong giao diện người dùng. Bạn có thể thực hiện việc này bằng cách xác minh rằng số lượng mục chính xác tồn tại trong RecyclerView.Adapter hoặc bằng cách lặp lại thông qua các chế độ xem hàng riêng lẻ và xác minh rằng dữ liệu có định dạng chính xác.