แนวคิดและการติดตั้งใช้งาน Jetpack Compose
การใช้ไลบรารีการแบ่งหน้าในแอปควรควบคู่ไปกับ
กลยุทธ์การทดสอบที่มีประสิทธิภาพ คุณควรทดสอบคอมโพเนนต์การโหลดข้อมูล เช่น
PagingSource และ RemoteMediator เพื่อให้แน่ใจว่าคอมโพเนนต์ทำงานได้ตามที่คาดไว้
นอกจากนี้ คุณควรเขียนการทดสอบตั้งแต่ต้นจนจบเพื่อยืนยันว่าคอมโพเนนต์ทั้งหมดในการใช้งานการแบ่งหน้าทำงานร่วมกันได้อย่างถูกต้องโดยไม่มีผลข้างเคียงที่ไม่คาดคิด
คู่มือนี้อธิบายวิธีการทดสอบไลบรารีการแบ่งหน้าในชั้นข้อมูลของแอป รวมถึงวิธีเขียนการทดสอบตั้งแต่ต้นจนจบสําหรับการติดตั้งใช้งานการแบ่งหน้าทั้งหมด
การทดสอบชั้นข้อมูล
เขียนการทดสอบหน่วยสำหรับคอมโพเนนต์ในชั้นข้อมูลเพื่อให้มั่นใจว่าคอมโพเนนต์จะโหลด
ข้อมูลจากแหล่งข้อมูลอย่างเหมาะสม ระบุเวอร์ชันปลอมของทรัพยากร Dependency เพื่อยืนยันว่าคอมโพเนนต์ที่กำลังทดสอบทำงานได้อย่างถูกต้องในการแยก คอมโพเนนต์อย่างหนึ่งที่คุณต้องทดสอบในเลเยอร์ที่เก็บ คือ RemoteMediator
การทดสอบ RemoteMediator รายการ
เป้าหมายของRemoteMediatorการทดสอบหน่วยคือการยืนยันว่าload()
ฟังก์ชันส่งคืน MediatorResult ที่ถูกต้อง การทดสอบผลข้างเคียง เช่น การแทรกข้อมูลลงในฐานข้อมูล เหมาะสำหรับการทดสอบการผสานรวมมากกว่า
ขั้นตอนแรกคือการพิจารณาว่าการติดตั้งใช้งานRemoteMediator
ของคุณต้องมีทรัพยากร Dependency ใดบ้าง ตัวอย่างต่อไปนี้แสดงการติดตั้งใช้งาน RemoteMediator
ที่ต้องใช้ฐานข้อมูล Room, อินเทอร์เฟซ Retrofit และสตริงการค้นหา
Java (RxJava)
public class PageKeyedRemoteMediator
extends RxRemoteMediator<Integer, RedditPost> {
@NonNull
private RedditDb db;
@NonNull
private RedditPostDao postDao;
@NonNull
private SubredditRemoteKeyDao remoteKeyDao;
@NonNull
private RedditApi redditApi;
@NonNull
private String subredditName;
public PageKeyedRemoteMediator(
@NonNull RedditDb db,
@NonNull RedditApi redditApi,
@NonNull String subredditName
) {
this.db = db;
this.postDao = db.posts();
this.remoteKeyDao = db.remoteKeys();
this.redditApi = redditApi;
this.subredditName = subredditName;
...
}
}
Java (Guava/LiveData)
public class PageKeyedRemoteMediator
extends ListenableFutureRemoteMediator<Integer, RedditPost> {
@NonNull
private RedditDb db;
@NonNull
private RedditPostDao postDao;
@NonNull
private SubredditRemoteKeyDao remoteKeyDao;
@NonNull
private RedditApi redditApi;
@NonNull
private String subredditName;
@NonNull
private Executor bgExecutor;
public PageKeyedRemoteMediator(
@NonNull RedditDb db,
@NonNull RedditApi redditApi,
@NonNull String subredditName,
@NonNull Executor bgExecutor
) {
this.db = db;
this.postDao = db.posts();
this.remoteKeyDao = db.remoteKeys();
this.redditApi = redditApi;
this.subredditName = subredditName;
this.bgExecutor = bgExecutor;
...
}
}
คุณสามารถระบุอินเทอร์เฟซ Retrofit และสตริงการค้นหาได้ตามที่แสดงในส่วนการทดสอบ PagingSource ของทดสอบการติดตั้งใช้งานการแบ่งหน้า
การจัดเตรียมฐานข้อมูล Room เวอร์ชันจำลองเป็นเรื่องที่ซับซ้อนมาก ดังนั้นการจัดเตรียมการใช้งานในหน่วยความจำของฐานข้อมูลแทนเวอร์ชันจำลองแบบเต็มจึงอาจง่ายกว่า เนื่องจากการสร้างฐานข้อมูล Room ต้องใช้Context
ออบเจ็กต์ คุณจึงต้องวางการทดสอบ RemoteMediator นี้ไว้ในไดเรกทอรี androidTest
และเรียกใช้ด้วยโปรแกรมเรียกใช้การทดสอบ AndroidJUnit4 เพื่อให้มีการเข้าถึงบริบทแอปพลิเคชันทดสอบ ดูข้อมูลเพิ่มเติมเกี่ยวกับการทดสอบแบบมีเครื่องควบคุมได้ที่
สร้างการทดสอบ 1 หน่วยแบบมีเครื่องควบคุม
กำหนดฟังก์ชันการล้างข้อมูลเพื่อให้แน่ใจว่าสถานะจะไม่รั่วไหลระหว่างฟังก์ชันการทดสอบ ซึ่งจะช่วยให้ผลลัพธ์ระหว่างการทดสอบมีความสอดคล้องกัน
Java (RxJava)
@RunWith(AndroidJUnit4.class)
public class PageKeyedRemoteMediatorTest {
static PostFactory postFactory = new PostFactory();
static List<RedditPost> mockPosts = new ArrayList<>();
static MockRedditApi mockApi = new MockRedditApi();
private RedditDb mockDb = RedditDb.Companion.create(
ApplicationProvider.getApplicationContext(),
true
);
static {
for (int i=0; i<3; i++) {
RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT);
mockPosts.add(post);
}
}
@After
public void tearDown() {
mockDb.clearAllTables();
// Clear the failure message after each test run.
mockApi.setFailureMsg(null);
// Clear out posts after each test run.
mockApi.clearPosts();
}
}
Java (Guava/LiveData)
@RunWith(AndroidJUnit4.class)
public class PageKeyedRemoteMediatorTest {
static PostFactory postFactory = new PostFactory();
static List<RedditPost> mockPosts = new ArrayList<>();
static MockRedditApi mockApi = new MockRedditApi();
private RedditDb mockDb = RedditDb.Companion.create(
ApplicationProvider.getApplicationContext(),
true
);
static {
for (int i=0; i<3; i++) {
RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT);
mockPosts.add(post);
}
}
@After
public void tearDown() {
mockDb.clearAllTables();
// Clear the failure message after each test run.
mockApi.setFailureMsg(null);
// Clear out posts after each test run.
mockApi.clearPosts();
}
}
ขั้นตอนถัดไปคือการทดสอบload()ฟังก์ชัน ในตัวอย่างนี้ มีกรณีที่ต้องทดสอบ 3 กรณี
ดังนี้
- กรณีแรกคือเมื่อ
mockApiแสดงข้อมูลที่ถูกต้องload()ฟังก์ชัน ควรแสดงผลMediatorResult.Successและพร็อพเพอร์ตี้endOfPaginationReachedควรเป็นfalse - กรณีที่ 2 คือเมื่อ
mockApiแสดงการตอบกลับที่สำเร็จ แต่ข้อมูลที่ส่งคืนว่างเปล่า ฟังก์ชันloadควรแสดงผลMediatorResult.Successและพร็อพเพอร์ตี้endOfPaginationReachedควรเป็นtrue - กรณีที่ 3 คือเมื่อ
mockApiโยนข้อยกเว้นเมื่อดึงข้อมูล ฟังก์ชันload()ควรแสดงผลMediatorResult.Error
ทำตามขั้นตอนต่อไปนี้เพื่อทดสอบกรณีแรก
- ตั้งค่า
mockApiด้วยข้อมูลโพสต์ที่จะส่งคืน - เริ่มต้นออบเจ็กต์
RemoteMediator - ทดสอบฟังก์ชัน
load
Java (RxJava)
@Test
public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent()
throws InterruptedException {
// Add mock results for the API to return.
for (RedditPost post: mockPosts) {
mockApi.addPost(post);
}
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
.test()
.await()
.assertValueCount(1)
.assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success &&
((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == false);
}
Java (Guava/LiveData)
@Test
public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent()
throws InterruptedException, ExecutionException {
// Add mock results for the API to return.
for (RedditPost post: mockPosts) {
mockApi.addPost(post);
}
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT,
new CurrentThreadExecutor()
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
RemoteMediator.MediatorResult result =
remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();
assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class));
assertFalse(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached());
}
การทดสอบครั้งที่ 2 กำหนดให้ mockApi แสดงผลลัพธ์ที่ว่างเปล่า เนื่องจากคุณล้างข้อมูลจาก mockApi หลังจากเรียกใช้การทดสอบแต่ละครั้ง ระบบจึงจะแสดงผลลัพธ์ที่ว่างเปล่าโดยค่าเริ่มต้น
Java (RxJava)
@Test
public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData()
throws InterruptedException() {
// To test endOfPaginationReached, don't set up the mockApi to return post
// data here.
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
.test()
.await()
.assertValueCount(1)
.assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success &&
((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == true);
}
Java (Guava/LiveData)
@Test
public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData()
throws InterruptedException, ExecutionException {
// To test endOfPaginationReached, don't set up the mockApi to return post
// data here.
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT,
new CurrentThreadExecutor()
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
RemoteMediator.MediatorResult result =
remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();
assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class));
assertTrue(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached());
}
การทดสอบขั้นสุดท้ายกำหนดให้ mockApi ส่งข้อยกเว้นเพื่อให้การทดสอบ
ยืนยันได้ว่าฟังก์ชัน load() ส่งคืน MediatorResult.Error อย่างถูกต้อง
Java (RxJava)
@Test
public void refreshLoadReturnsErrorResultWhenErrorOccurs()
throws InterruptedException {
// Set up failure message to throw exception from the mock API.
mockApi.setFailureMsg("Throw test failure");
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
.test()
.await()
.assertValueCount(1)
.assertValue(value -> value instanceof RemoteMediator.MediatorResult.Error);
}
Java (Guava/LiveData)
@Test
public void refreshLoadReturnsErrorResultWhenErrorOccurs()
throws InterruptedException, ExecutionException {
// Set up failure message to throw exception from the mock API.
mockApi.setFailureMsg("Throw test failure");
PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT,
new CurrentThreadExecutor()
);
PagingState<Integer, RedditPost> pagingState = new PagingState<>(
new ArrayList(),
null,
new PagingConfig(10),
10
);
RemoteMediator.MediatorResult result =
remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();
assertThat(result, instanceOf(RemoteMediator.MediatorResult.Error.class));
}
การทดสอบตั้งแต่ต้นจนจบ
การทดสอบหน่วยช่วยให้มั่นใจได้ว่าคอมโพเนนต์การแบ่งหน้าแต่ละรายการทำงานแยกกัน แต่การทดสอบแบบครบวงจรจะช่วยให้มั่นใจได้มากขึ้นว่าแอปพลิเคชันทำงานได้โดยรวม การทดสอบเหล่านี้ยังคงต้องใช้การจำลองการอ้างอิง แต่โดยทั่วไปแล้วจะครอบคลุมโค้ดของแอปส่วนใหญ่
ตัวอย่างในส่วนนี้ใช้ทรัพยากร Dependency ของ API แบบจำลองเพื่อหลีกเลี่ยงการใช้ระบบเครือข่ายในการทดสอบ ระบบจะกำหนดค่า API จำลองให้แสดงชุดข้อมูลทดสอบที่สอดคล้องกัน ซึ่งจะทำให้การทดสอบทำซ้ำได้ ตัดสินใจว่าจะแทนที่การอ้างอิงใดด้วยการติดตั้งใช้งานจำลองโดยพิจารณาจากสิ่งที่แต่ละการอ้างอิงทำ ความสอดคล้องของเอาต์พุต และความเที่ยงตรงที่คุณต้องการจากการทดสอบ
เขียนโค้ดในลักษณะที่ช่วยให้คุณสลับเวอร์ชันจำลองของ การอ้างอิงได้อย่างง่ายดาย ตัวอย่างต่อไปนี้ใช้การติดตั้งใช้งานตัวระบุตำแหน่งบริการพื้นฐานเพื่อระบุและเปลี่ยนทรัพยากร Dependency ตามต้องการ ในแอปขนาดใหญ่ การใช้ไลบรารีการขึ้นต่อกัน เช่น Hilt จะช่วยจัดการกราฟการขึ้นต่อกันที่ซับซ้อนมากขึ้นได้
Kotlin
class RedditActivityTest {
companion object {
private const val TEST_SUBREDDIT = "test"
}
private val postFactory = PostFactory()
private val mockApi = MockRedditApi().apply {
addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT))
addPost(postFactory.createRedditPost(TEST_SUBREDDIT))
addPost(postFactory.createRedditPost(TEST_SUBREDDIT))
}
@Before
fun init() {
val app = ApplicationProvider.getApplicationContext<Application>()
// Use a controlled service locator with a mock API.
ServiceLocator.swap(
object : DefaultServiceLocator(app = app, useInMemoryDb = true) {
override fun getRedditApi(): RedditApi = mockApi
}
)
}
}
Java (RxJava)
public class RedditActivityTest {
public static final String TEST_SUBREDDIT = "test";
private static PostFactory postFactory = new PostFactory();
private static MockRedditApi mockApi = new MockRedditApi();
static {
mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
}
@Before
public void setup() {
Application app = ApplicationProvider.getApplicationContext();
// Use a controlled service locator with a mock API.
ServiceLocator.Companion.swap(
new DefaultServiceLocator(app, true) {
@NotNull
@Override
public RedditApi getRedditApi() {
return mockApi;
}
}
);
}
}
Java (Guava/LiveData)
public class RedditActivityTest {
public static final String TEST_SUBREDDIT = "test";
private static PostFactory postFactory = new PostFactory();
private static MockRedditApi mockApi = new MockRedditApi();
static {
mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
}
@Before
public void setup() {
Application app = ApplicationProvider.getApplicationContext();
// Use a controlled service locator with a mock API.
ServiceLocator.Companion.swap(
new DefaultServiceLocator(app, true) {
@NotNull
@Override
public RedditApi getRedditApi() {
return mockApi;
}
}
);
}
}
หลังจากตั้งค่าโครงสร้างการทดสอบแล้ว ขั้นตอนถัดไปคือการยืนยันว่าข้อมูลที่Pagerการติดตั้งใช้งานส่งคืนมานั้นถูกต้อง การทดสอบหนึ่งควรตรวจสอบว่าออบเจ็กต์ Pager โหลดข้อมูลเริ่มต้นเมื่อหน้าเว็บโหลดครั้งแรก และการทดสอบอีกรายการควรตรวจสอบว่าออบเจ็กต์ Pager โหลดข้อมูลเพิ่มเติมอย่างถูกต้องตามข้อมูลจากผู้ใช้
ในตัวอย่างต่อไปนี้ การทดสอบจะยืนยันว่าออบเจ็กต์ Pager จะอัปเดต RecyclerView.Adapter ด้วยจำนวนรายการที่ถูกต้องซึ่งส่งคืนจาก API
เมื่อผู้ใช้ป้อนซับเรดดิตอื่นเพื่อค้นหา
Kotlin
@Test
fun loadsTheDefaultResults() {
ActivityScenario.launch(RedditActivity::class.java)
onView(withId(R.id.list)).check { view, noViewFoundException ->
if (noViewFoundException != null) {
throw noViewFoundException
}
val recyclerView = view as RecyclerView
assertEquals(1, recyclerView.adapter?.itemCount)
}
}
@Test
// Verify that the default data is swapped out when the user searches for a
// different subreddit.
fun loadsTheTestResultsWhenSearchingForSubreddit() {
ActivityScenario.launch(RedditActivity::class.java )
onView(withId(R.id.list)).check { view, noViewFoundException ->
if (noViewFoundException != null) {
throw noViewFoundException
}
val recyclerView = view as RecyclerView
// Verify that it loads the default data first.
assertEquals(1, recyclerView.adapter?.itemCount)
}
// Search for test subreddit instead of default to trigger new data load.
onView(withId(R.id.input)).perform(
replaceText(TEST_SUBREDDIT),
pressKey(KeyEvent.KEYCODE_ENTER)
)
onView(withId(R.id.list)).check { view, noViewFoundException ->
if (noViewFoundException != null) {
throw noViewFoundException
}
val recyclerView = view as RecyclerView
assertEquals(2, recyclerView.adapter?.itemCount)
}
}
Java (RxJava)
@Test
public void loadsTheDefaultResults() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
}
@Test
// Verify that the default data is swapped out when the user searches for a
// different subreddit.
public void loadsTheTestResultsWhenSearchingForSubreddit() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
// Verify that it loads the default data first.
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
// Search for test subreddit instead of default to trigger new data load.
onView(withId(R.id.input)).perform(
replaceText(TEST_SUBREDDIT),
pressKey(KeyEvent.KEYCODE_ENTER)
);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(2, recyclerView.getAdapter().getItemCount());
});
}
Java (Guava/LiveData)
@Test
public void loadsTheDefaultResults() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
}
@Test
// Verify that the default data is swapped out when the user searches for a
// different subreddit.
public void loadsTheTestResultsWhenSearchingForSubreddit() {
ActivityScenario.launch(RedditActivity.class);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
// Verify that it loads the default data first.
assertEquals(1, recyclerView.getAdapter().getItemCount());
});
// Search for test subreddit instead of default to trigger new data load.
onView(withId(R.id.input)).perform(
replaceText(TEST_SUBREDDIT),
pressKey(KeyEvent.KEYCODE_ENTER)
);
onView(withId(R.id.list)).check((view, noViewFoundException) -> {
if (noViewFoundException != null) {
throw noViewFoundException;
}
RecyclerView recyclerView = (RecyclerView) view;
assertEquals(2, recyclerView.getAdapter().getItemCount());
});
}
การทดสอบที่มีการวัดควรยืนยันว่าข้อมูลแสดงอย่างถูกต้องใน UI ทำได้โดยการยืนยันว่ามีจำนวนสินค้าที่ถูกต้องใน
RecyclerView.Adapter หรือโดยการวนซ้ำผ่านมุมมองแถวแต่ละรายการและ
ยืนยันว่าข้อมูลได้รับการจัดรูปแบบอย่างถูกต้อง