앱에서 Paging 라이브러리를 구현하는 작업에는 강력한 테스트 전략이 필요합니다. 데이터 로드 구성요소(예: PagingSource
, RemoteMediator
)를 테스트하여 제대로 작동하는지 확인해야 합니다. 또한 엔드 투 엔드 테스트를 작성하여 Paging 구현의 모든 구성요소가 예기치 않은 부작용 없이 제대로 작동하는지 확인해야 합니다.
이 가이드에서는 앱의 다양한 아키텍처 레이어에서 Paging 라이브러리를 테스트하는 방법과 전체 Paging 구현을 위한 엔드 투 엔드 테스트를 작성하는 방법을 설명합니다.
UI 레이어 테스트
Paging 라이브러리를 사용하여 가져온 데이터는 UI에서 Flow<PagingData<Value>>
로 사용됩니다.
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
의 fake에서 반환된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
입니다. 다음 섹션의 예는 네트워크를 사용한 페이징 샘플을 기반으로 합니다.
PagingSource 테스트
PagingSource
클래스 단위 테스트에는 PagingSource
인스턴스 설정과 load()
함수가 LoadParams
인수에 기반하여 페이징된 정확한 데이터를 반환하는지 확인하는 작업이 포함됩니다. 모의 데이터를 PagingSource
생성자에 제공하여 테스트에서 데이터를 제어할 수 있도록 합니다. 기본 데이터 유형의 기본값을 전달할 수 있지만 데이터베이스나 API 구현과 같은 다른 객체의 경우에는 모의 버전을 전달해야 합니다. 이렇게 하면 테스트 중인 PagingSource
가 상호작용할 때 모의 데이터 소스의 출력을 완전히 제어할 수 있습니다.
특정 PagingSource
구현의 생성자 매개변수는 전달해야 하는 테스트 데이터 유형을 지정합니다. 다음 예에서 PagingSource
구현에는 RedditApi
객체와 하위 Reddit 이름에 대한 String
이 필요합니다. String
매개변수의 기본값을 전달할 수 있지만 RedditApi
매개변수의 경우 테스트를 위한 모의 테스트 구현을 만들어야 합니다.
Kotlin
class ItemKeyedSubredditPagingSource( private val redditApi: RedditApi, private val subredditName: String ) : PagingSource<String, RedditPost>() { ... }
자바
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; ... } }
자바
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) } }
자바
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>
자바
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()
메서드가 테스트의 핵심입니다. 다음 예는 지정된 LoadParams
매개변수에 대해 load()
메서드가 정확한 데이터, 이전 키, 다음 키를 반환하는지 확인하는 어설션을 보여줍니다.
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 ) ), ) }
자바
@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() )); }
자바
@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 ) ), ) }
자바
@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() )); }
자바
@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>() { ... }
자바
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; ... } }
자바
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() } }
자바
@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(); } }
자바
@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 } }
자바
@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); }
자바
@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 } }
자바
@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); }
자바
@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()); }
최종 테스트에서는 load()
함수가 정확하게 MediatorResult.Error
를 반환하는지 확인할 수 있도록 mockApi
가 예외를 발생시켜야 합니다.
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 } }
자바
@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); }
자바
@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)); }
엔드 투 엔드 테스트
단위 테스트는 개별 Paging 구성요소가 격리된 상태로 작동하도록 보장하지만 엔드 투 엔드 테스트는 애플리케이션이 전체적으로 작동하도록 보장합니다. 이러한 테스트에는 여전히 모의 종속 항목이 필요하지만 일반적으로 대부분의 앱 코드가 포함됩니다.
이 섹션의 예는 테스트에서 네트워크를 사용하지 않도록 모의 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 } ) } }
자바
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; } } ); } }
자바
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
객체가 사용자 입력에 따라 추가 데이터를 정확하게 로드하는지 확인해야 합니다. 다음 예의 테스트에서는 사용자가 검색할 다른 하위 Reddit을 입력할 때 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) } }
자바
@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()); }); }
자바
@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
에 있는지 확인하거나 개별 행 뷰를 반복하고 데이터 형식이 정확한지 확인하면 됩니다.