在應用程式中實作分頁程式庫時,應搭配完善的測試策略。建議您測試 PagingSource
和 RemoteMediator
等資料載入元件,確保元件運作正常。此外,您也應撰寫端對端測試,確認 Paging 實作中的所有元件都能正常運作,而不會產生意外的副作用。
本指南說明如何在應用程式的不同架構層測試 Paging 程式庫,以及如何為整個 Paging 實作撰寫端對端測試。
UI 層測試
透過 Paging 程式庫擷取的資料會做為 Flow<PagingData<Value>>
在 UI 中取用。如要撰寫測試,驗證 UI 中的資料是否符合預期,請納入 paging-testing
依附元件。這個依附元件包含 Flow<PagingData<Value>>
的 asSnapshot()
擴充功能,可讓您使用標準的 List<Value>
,宣告包含預期元素的已分頁資料,如以下程式碼片段所示:
fun test_items_contain_one_to_ten() = runTest {
// Get the Flow of PagingData from the ViewModel under test
val items: Flow<PagingData<String>> = viewModel.items
val itemsSnapshot: List<String> = items.asSnapshot(
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
)
}
測試轉換作業
您也應該撰寫單元測試,涵蓋套用至 PagingData
串流的所有轉換作業。如何編寫這類測試:
- 在
Flow<List<Value>>
上使用asPagingSourceFactory
擴充功能。 - 在
Repository
的假例項中,使用傳回的PagingSourceFactory
。 - 將該
Repository
傳遞至ViewModel
。
接著,您就可以依據上一節的說明測試 ViewModel
。請考量以下 ViewModel
:
class MyViewModel(
myRepository: myRepository
) {
val items = Pager(
config: PagingConfig,
initialKey = null,
pagingSourceFactory = { myRepository.pagingSource() }
)
.flow
.map { pagingData ->
pagingData.insertSeparators<String, String> { before, _ ->
when {
// Add a dashed String separator if the prior item is a multiple of 10
before.last() == '0' -> "---------"
// Return null to avoid adding a separator between two items.
else -> null
}
}
}
如要在 MyViewModel
中測試轉換作業,請針對為了代表所要轉換的資料而委派至 Flow
的 MyRepository
提供假例項,如以下程式碼片段所示:
class FakeMyRepository(
testScope: CoroutineScope
): MyRepository {
private val itemsFlow = flowOf((0..100).map(Any::toString))
private val pagingSourceFactory = itemsFlow.asPagingSourceFactory(
coroutineScope = testScope
)
val pagingSource = pagingSourceFactory()
}
接著,您就能為分隔符邏輯撰寫測試,如下列程式碼片段所示:
fun test_separators_are_added_every_10_items() = runTest {
// Create your ViewModel
val viewModel = MyViewModel(
myRepository = FakeMyRepository()
)
// Get the Flow of PagingData from the ViewModel with the separator transformations applied
val items: Flow<PagingData<String>> = viewModel.items
val snapshot: List<String> = items.asSnapshot(coroutineScope = this) {}
// With the asSnapshot complete, you can now verify that the snapshot
// has the expected separators
}
資料層測試
請針對資料層的元件撰寫單元測試,確保這些元件能妥善從資料來源載入資料。您可以提供依附元件的模擬版本,獨立驗證受測元件是否能正常運作。需要測試的存放區層主要元件為 PagingSource
和 RemoteMediator
。後續章節中的範例是以「搭配使用 Paging 與網路的範例」為基礎。
PagingSource 測試
PagingSource
類別的單元測試涉及設定 PagingSource
執行個體,並確認 load()
函式會根據 LoadParams
引數傳回正確的分頁資料。請將模擬資料提供給 PagingSource
建構函式,以便控管測試中的資料。您可以傳遞原始資料類型的預設值,但必須傳遞其他物件的模擬版本,例如資料庫或 API 實作項目。如此一來,當受測的 PagingSource
與模擬資料互動時,您就能完整控管模擬資料來源的輸出內容。
您需要傳遞的測試資料類型,取決於您的特定 PagingSource
實作建構函式參數。在以下範例中,PagingSource
實作需要 RedditApi
物件,以及子版 (subreddit) 名稱的 String
。您可以傳遞 String
參數的預設值,但如果是 RedditApi
參數,則必須建立模擬測試。
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; ... } }
在此範例中,RedditApi
參數是一種可定義伺服器要求和回應類別的 Retrofit 介面。模擬版本可以實作介面、覆寫任何必要函式,並提供便利的方法,設定模擬物件在測試中的預期回應方式。
模擬物件設定完成後,請設定依附元件並將測試中的 PagingSource
物件初始化。以下範例說明如何透過測試貼文清單來初始化 MockRedditApi
物件:
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>
下一步是測試 PagingSource
物件。load()
方法是測試的主要重點。下列範例說明如何驗證 load()
方法會傳回特定 LoadParams
參數的正確資料、上一個鍵和下一個鍵:
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)); }
以上範例示範如何測試透過項目鍵分頁的 PagingSource
。如果您使用的資料來源是採用頁面鍵分頁,則 PagingSource
測試會有所不同。主要差異在於 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)); }
RemoteMediator 測試
RemoteMediator
單元測試的目標是驗證 load()
函式會傳回正確的 MediatorResult
。
針對副作用 (例如插入資料庫的資料) 的測試較適用於整合測試。
首先,您必須決定 RemoteMediator
實作所需要的依附元件。以下範例說明需要 Room 資料庫、Retrofit 介面和搜尋字串的 RemoteMediator
實作:
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; ... } }
您可以按照 PagingSource 測試一節的說明,來提供 Retrofit 介面和搜尋字串。提供 Room 資料庫的模擬版本十分複雜,因此較簡單的做法是提供資料庫的記憶體內實作,而不是完整的模擬版本。由於建立 Room 資料庫需要 Context
物件,因此您必須將此 RemoteMediator
測試放在 androidTest
目錄中,並使用 AndroidJUnit4 測試執行工具,以便存取測試應用程式的內容。如要進一步瞭解檢測設備測試,請參閱建構檢測設備單元測試。
定義拆除函式,確保狀態在測試函式之間不會外洩。這樣可確保測試執行結果維持一致。
Kotlin
@ExperimentalPagingApi @OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class PageKeyedRemoteMediatorTest { private val postFactory = PostFactory() private val mockPosts = listOf( postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT), postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT), postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT) ) private val mockApi = mockRedditApi() private val mockDb = RedditDb.create( ApplicationProvider.getApplicationContext(), useInMemory = true ) @After fun tearDown() { mockDb.clearAllTables() // Clear out failure message to default to the successful response. mockApi.failureMsg = null // Clear out posts after each test run. mockApi.clearPosts() } }
Java
@RunWith(AndroidJUnit4.class) public class PageKeyedRemoteMediatorTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); private RedditDb mockDb = RedditDb.Companion.create( ApplicationProvider.getApplicationContext(), true ); static { for (int i=0; i<3; i++) { RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT); mockPosts.add(post); } } @After public void tearDown() { mockDb.clearAllTables(); // Clear the failure message after each test run. mockApi.setFailureMsg(null); // Clear out posts after each test run. mockApi.clearPosts(); } }
Java
@RunWith(AndroidJUnit4.class) public class PageKeyedRemoteMediatorTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); private RedditDb mockDb = RedditDb.Companion.create( ApplicationProvider.getApplicationContext(), true ); static { for (int i=0; i<3; i++) { RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT); mockPosts.add(post); } } @After public void tearDown() { mockDb.clearAllTables(); // Clear the failure message after each test run. mockApi.setFailureMsg(null); // Clear out posts after each test run. mockApi.clearPosts(); } }
下一步是測試 load()
函式。在這個範例中,有三個案例可以進行測試:
- 第一個案例是
mockApi
傳回有效的資料。load()
函式應傳回MediatorResult.Success
,而endOfPaginationReached
屬性應為false
。 - 第二個案例是
mockApi
傳回成功的回應,但傳回的資料是空的。load()
函式應傳回MediatorResult.Success
,而endOfPaginationReached
屬性應為true
。 - 第三個案例是
mockApi
在擷取資料時擲回例外狀況。load()
函式應會傳回MediatorResult.Error
。
請按照下列步驟測試第一個案例:
- 使用要傳回的訊息資料設定
mockApi
。 - 將
RemoteMediator
物件初始化。 - 測試
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()); }
如要進行第二個測試,mockApi
必須傳回空白結果。由於每次執行測試後都會清除 mockApi
中的資料,因此根據預設將會傳回空白結果。
Kotlin
@Test fun refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() = runTest { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. val remoteMediator = PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ) val pagingState = PagingState<Int, RedditPost>( listOf(), null, PagingConfig(10), 10 ) val result = remoteMediator.load(LoadType.REFRESH, pagingState) assertTrue { result is MediatorResult.Success } assertTrue { (result as MediatorResult.Success).endOfPaginationReached } }
Java
@Test public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() throws InterruptedException() { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success && ((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == true); }
Java
@Test public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() throws InterruptedException, ExecutionException { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class)); assertTrue(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached()); }
如要進行最後一個測試,mockApi
必須擲回例外狀況,以便驗證 load()
函式是否正確傳回 MediatorResult.Error
。
Kotlin
@Test fun refreshLoadReturnsErrorResultWhenErrorOccurs() = runTest { // Set up failure message to throw exception from the mock API. mockApi.failureMsg = "Throw test failure" val remoteMediator = PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ) val pagingState = PagingState<Int, RedditPost>( listOf(), null, PagingConfig(10), 10 ) val result = remoteMediator.load(LoadType.REFRESH, pagingState) assertTrue {result is MediatorResult.Error } }
Java
@Test public void refreshLoadReturnsErrorResultWhenErrorOccurs() throws InterruptedException { // Set up failure message to throw exception from the mock API. mockApi.setFailureMsg("Throw test failure"); PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Error); }
Java
@Test public void refreshLoadReturnsErrorResultWhenErrorOccurs() throws InterruptedException, ExecutionException { // Set up failure message to throw exception from the mock API. mockApi.setFailureMsg("Throw test failure"); PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Error.class)); }
端對端測試
單元測試可保證個別分頁元件能夠獨立運作,但端對端測試更能確保應用程式整體都能正常運作。這些測試仍需要一些模擬依附元件,但通常已涵蓋大部分的應用程式程式碼。
本節中的範例使用模擬 API 依附元件,避免在測試中使用網路。模擬 API 已設為傳回一組一致的測試資料,進而產生可重複的測試。根據依附元件的作用、輸出內容的一致性,以及測試所需的擬真度,決定要針對模擬實作替換的依附元件。
編寫程式碼時,請採取可讓您輕鬆替換依附元件模擬版本的方式。以下範例使用基本的服務定位器實作,並視需要提供及變更依附元件。在大型應用程式中,使用依附元件插入程式庫 (例如 Hilt) 有助管理更複雜的依附元件圖表。
Kotlin
class RedditActivityTest { companion object { private const val TEST_SUBREDDIT = "test" } private val postFactory = PostFactory() private val mockApi = MockRedditApi().apply { addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)) addPost(postFactory.createRedditPost(TEST_SUBREDDIT)) addPost(postFactory.createRedditPost(TEST_SUBREDDIT)) } @Before fun init() { val app = ApplicationProvider.getApplicationContext<Application>() // Use a controlled service locator with a mock API. ServiceLocator.swap( object : DefaultServiceLocator(app = app, useInMemoryDb = true) { override fun getRedditApi(): RedditApi = mockApi } ) } }
Java
public class RedditActivityTest { public static final String TEST_SUBREDDIT = "test"; private static PostFactory postFactory = new PostFactory(); private static MockRedditApi mockApi = new MockRedditApi(); static { mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); } @Before public void setup() { Application app = ApplicationProvider.getApplicationContext(); // Use a controlled service locator with a mock API. ServiceLocator.Companion.swap( new DefaultServiceLocator(app, true) { @NotNull @Override public RedditApi getRedditApi() { return mockApi; } } ); } }
Java
public class RedditActivityTest { public static final String TEST_SUBREDDIT = "test"; private static PostFactory postFactory = new PostFactory(); private static MockRedditApi mockApi = new MockRedditApi(); static { mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); } @Before public void setup() { Application app = ApplicationProvider.getApplicationContext(); // Use a controlled service locator with a mock API. ServiceLocator.Companion.swap( new DefaultServiceLocator(app, true) { @NotNull @Override public RedditApi getRedditApi() { return mockApi; } } ); } }
設定測試結構後,下一步就是驗證 Pager
實作傳回的資料是否正確。一項測試應確保 Pager
物件在網頁初次載入時載入預設資料,而另一項測試應驗證 Pager
物件是否可根據使用者輸入內容正確載入額外資料。在以下範例中,測試會驗證當使用者輸入不同的子版 (subreddit) 進行搜尋時,Pager
物件是否可根據 API 傳回的正確項目數量更新 RecyclerView.Adapter
。
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()); }); }
檢測設備測試應確認資料可正確顯示在使用者介面中。方法是驗證 RecyclerView.Adapter
中的項目數量正確,或反覆檢視個別的資料列檢視畫面,並確認資料格式正確。