Các khái niệm và cách triển khai Jetpack Compose
Việc triển khai thư viện Paging 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ư
PagingSource và RemoteMediator để đảm bảo các thành phần này 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 Paging (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ử thư viện Paging trong lớp dữ liệu của ứng dụng cũng như cách viết mã kiểm thử toàn diện cho toàn bộ quá trình triển khai Paging.
Kiểm thử lớp dữ liệu
Viết mã kiểm thử đơn vị cho các thành phần trong lớp dữ liệu 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 phiên bản giả của
các phần phụ thuộc để bảo đảm các thành phần đang được kiểm thử hoạt động đúng cách khi
tách biệt. Một trong những thành phần bạn cần kiểm thử trong lớp kho lưu trữ là RemoteMediator.
Kiểm thử RemoteMediator
Mục tiêu của kiểm thử đơn vị RemoteMediator là nhằm xác minh rằng hàm load()
trả về MediatorResult chính xác. Các loại hình kiểm thử về tác dụng phụ, chẳng hạn như dữ liệu bị 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. Quá trình này yêu cầu cơ sở dữ liệu Room, giao diện Retrofit và cụm từ tìm kiếm:
Java (RxJava)
public class PageKeyedRemoteMediator
extends RxRemoteMediator<Integer, RedditPost> {
@NonNull
private RedditDb db;
@NonNull
private RedditPostDao postDao;
@NonNull
private SubredditRemoteKeyDao remoteKeyDao;
@NonNull
private RedditApi redditApi;
@NonNull
private String subredditName;
public PageKeyedRemoteMediator(
@NonNull RedditDb db,
@NonNull RedditApi redditApi,
@NonNull String subredditName
) {
this.db = db;
this.postDao = db.posts();
this.remoteKeyDao = db.remoteKeys();
this.redditApi = redditApi;
this.subredditName = subredditName;
...
}
}
Java (Guava/LiveData)
public class PageKeyedRemoteMediator
extends ListenableFutureRemoteMediator<Integer, RedditPost> {
@NonNull
private RedditDb db;
@NonNull
private RedditPostDao postDao;
@NonNull
private SubredditRemoteKeyDao remoteKeyDao;
@NonNull
private RedditApi redditApi;
@NonNull
private String subredditName;
@NonNull
private Executor bgExecutor;
public PageKeyedRemoteMediator(
@NonNull RedditDb db,
@NonNull RedditApi redditApi,
@NonNull String subredditName,
@NonNull Executor bgExecutor
) {
this.db = db;
this.postDao = db.posts();
this.remoteKeyDao = db.remoteKeys();
this.redditApi = redditApi;
this.subredditName = subredditName;
this.bgExecutor = bgExecutor;
...
}
}
Bạn có thể cung cấp giao diện Retrofit và cụm từ tìm kiếm như minh hoạ trong
mục kiểm thử PagingSource của phần Kiểm thử quá trình triển khai Paging.
Việc cung cấp một phiên bản mô phỏng của cơ sở dữ liệu Room rất quan trọng. 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 Room yêu cầu phải có Context
đối tượng, 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ử.
Java (RxJava)
@RunWith(AndroidJUnit4.class)
public class PageKeyedRemoteMediatorTest {
static PostFactory postFactory = new PostFactory();
static List<RedditPost> mockPosts = new ArrayList<>();
static MockRedditApi mockApi = new MockRedditApi();
private RedditDb mockDb = RedditDb.Companion.create(
ApplicationProvider.getApplicationContext(),
true
);
static {
for (int i=0; i<3; i++) {
RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT);
mockPosts.add(post);
}
}
@After
public void tearDown() {
mockDb.clearAllTables();
// Clear the failure message after each test run.
mockApi.setFailureMsg(null);
// Clear out posts after each test run.
mockApi.clearPosts();
}
}
Java (Guava/LiveData)
@RunWith(AndroidJUnit4.class)
public class PageKeyedRemoteMediatorTest {
static PostFactory postFactory = new PostFactory();
static List<RedditPost> mockPosts = new ArrayList<>();
static MockRedditApi mockApi = new MockRedditApi();
private RedditDb mockDb = RedditDb.Companion.create(
ApplicationProvider.getApplicationContext(),
true
);
static {
for (int i=0; i<3; i++) {
RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT);
mockPosts.add(post);
}
}
@After
public void tearDown() {
mockDb.clearAllTables();
// Clear the failure message after each test run.
mockApi.setFailureMsg(null);
// Clear out posts after each test run.
mockApi.clearPosts();
}
}
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à
mockApitrả về dữ liệu hợp lệ. Hàmload()cần trả vềMediatorResult.Successvà thuộc tínhendOfPaginationReachedphải làfalse. - Trường hợp thứ hai là
mockApitrả về một phản hồi thành công, nhưng dữ liệu trả về bị trống. Hàmloadcần trả vềMediatorResult.Successvà thuộc tínhendOfPaginationReachedphải làtrue. - Trường hợp thứ ba là
mockApigửi một ngoại lệ khi tìm nạp dữ liệu. Hàmload()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:
- Thiết lập
mockApivới dữ liệu bài đăng cần trả về. - Khởi động đối tượng
RemoteMediator. - Kiểm tra hàm
load.
Java (RxJava)
@Test
public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent()
throws InterruptedException {
// Add mock results for the API to return.
for (RedditPost post: mockPosts) {
mockApi.addPost(post);
}
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
.test()
.await()
.assertValueCount(1)
.assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success &&
((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == false);
}
Java (Guava/LiveData)
@Test
public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent()
throws InterruptedException, ExecutionException {
// Add mock results for the API to return.
for (RedditPost post: mockPosts) {
mockApi.addPost(post);
}
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT,
new CurrentThreadExecutor()
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
RemoteMediator.MediatorResult result =
remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();
assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class));
assertFalse(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached());
}
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.
Java (RxJava)
@Test
public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData()
throws InterruptedException() {
// To test endOfPaginationReached, don't set up the mockApi to return post
// data here.
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
.test()
.await()
.assertValueCount(1)
.assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success &&
((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == true);
}
Java (Guava/LiveData)
@Test
public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData()
throws InterruptedException, ExecutionException {
// To test endOfPaginationReached, don't set up the mockApi to return post
// data here.
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT,
new CurrentThreadExecutor()
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
RemoteMediator.MediatorResult result =
remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();
assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class));
assertTrue(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached());
}
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.
Java (RxJava)
@Test
public void refreshLoadReturnsErrorResultWhenErrorOccurs()
throws InterruptedException {
// Set up failure message to throw exception from the mock API.
mockApi.setFailureMsg("Throw test failure");
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
.test()
.await()
.assertValueCount(1)
.assertValue(value -> value instanceof RemoteMediator.MediatorResult.Error);
}
Java (Guava/LiveData)
@Test
public void refreshLoadReturnsErrorResultWhenErrorOccurs()
throws InterruptedException, ExecutionException {
// Set up failure message to throw exception from the mock API.
mockApi.setFailureMsg("Throw test failure");
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT,
new CurrentThreadExecutor()
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
RemoteMediator.MediatorResult result =
remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();
assertThat(result, instanceOf(RemoteMediator.MediatorResult.Error.class));
}
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 Paging riêng lẻ hoạt động tách biệt, nhưng loại hình kiểm thử toàn diện đảm bảo 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ã trong ứ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 dữ liệu kiểm thử nhất quán, giúp các lần kiểm thử có thể lặp lại. Bạn có thể quyết định cần hoán đổi phần phụ thuộc nào trong 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 theo cách dễ dàng cho phép 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, 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 (RxJava)
public class RedditActivityTest {
public static final String TEST_SUBREDDIT = "test";
private static PostFactory postFactory = new PostFactory();
private static MockRedditApi mockApi = new MockRedditApi();
static {
mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
}
@Before
public void setup() {
Application app = ApplicationProvider.getApplicationContext();
// Use a controlled service locator with a mock API.
ServiceLocator.Companion.swap(
new DefaultServiceLocator(app, true) {
@NotNull
@Override
public RedditApi getRedditApi() {
return mockApi;
}
}
);
}
}
Java (Guava/LiveData)
public class RedditActivityTest {
public static final String TEST_SUBREDDIT = "test";
private static PostFactory postFactory = new PostFactory();
private static MockRedditApi mockApi = new MockRedditApi();
static {
mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
}
@Before
public void setup() {
Application app = ApplicationProvider.getApplicationContext();
// Use a controlled service locator with a mock API.
ServiceLocator.Companion.swap(
new DefaultServiceLocator(app, true) {
@NotNull
@Override
public RedditApi getRedditApi() {
return mockApi;
}
}
);
}
}
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 (RxJava)
@Test
public void loadsTheDefaultResults() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
}
@Test
// Verify that the default data is swapped out when the user searches for a
// different subreddit.
public void loadsTheTestResultsWhenSearchingForSubreddit() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
// Verify that it loads the default data first.
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
// Search for test subreddit instead of default to trigger new data load.
onView(withId(R.id.input)).perform(
replaceText(TEST_SUBREDDIT),
pressKey(KeyEvent.KEYCODE_ENTER)
);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(2, recyclerView.getAdapter().getItemCount());
});
}
Java (Guava/LiveData)
@Test
public void loadsTheDefaultResults() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
}
@Test
// Verify that the default data is swapped out when the user searches for a
// different subreddit.
public void loadsTheTestResultsWhenSearchingForSubreddit() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
// Verify that it loads the default data first.
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
// Search for test subreddit instead of default to trigger new data load.
onView(withId(R.id.input)).perform(
replaceText(TEST_SUBREDDIT),
pressKey(KeyEvent.KEYCODE_ENTER)
);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(2, recyclerView.getAdapter().getItemCount());
});
}
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.