앱에서 Paging 라이브러리를 구현하는 작업에는 강력한 테스트 전략이 필요합니다. 데이터 로드 구성요소(예: PagingSource, RemoteMediator)를 테스트하여 제대로 작동하는지 확인해야 합니다. 또한 엔드 투 엔드 테스트를 작성하여 Paging 구현의 모든 구성요소가 예기치 않은 부작용 없이 제대로 작동하는지 확인해야 합니다.
이 가이드에서는 앱의 다양한 아키텍처 레이어에서 Paging 라이브러리를 테스트하는 방법과 전체 Paging 구현을 위한 엔드 투 엔드 테스트를 작성하는 방법을 설명합니다.
UI 레이어 테스트
Compose는 Paging 데이터를
선언적으로collectAsLazyPagingItems 사용하므로 UI 레이어 테스트는 ViewModel에서 내보낸
에만Flow<PagingData<Value>> 집중할 수 있습니다. UI의 데이터가 예상대로인지 확인하는 테스트를 작성하려면 paging-testing 종속 항목을 포함하세요.
에 asSnapshot 확장 프로그램이 포함되어 있습니다.Flow<PagingData<Value>> 스크롤 상호작용을 모의 처리할 수 있는 람다 수신기에 API를 제공합니다. 모의 처리된 스크롤 상호작용에서 생성된 표준 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 {
// Scroll to the 50th item in the list. This will also suspend till
// the prefetch requirement is met if there's one.
// It also suspends until all loading is complete.
scrollTo(index = 50)
}
// With the asSnapshot complete, you can now verify that the snapshot
// has the expected values
assertEquals(
expected = (0..50).map(Int::toString),
actual = itemsSnapshot
)
}
또는 아래 스니펫과 같이 지정된 조건자가 충족될 때까지 스크롤할 수 있습니다.
fun test_footer_is_visible() = runTest {
// Get the Flow of PagingData from the ViewModel under test
val items: Flow<PagingData<String>> = viewModel.items
val itemsSnapshot: List<String> = items.asSnapshot {
// Scroll till the footer is visible
appendScrollWhile { item: String -> item != "Footer" }
}
변환 테스트
또한, PagingData 스트림에 적용하는 모든 변환을 다루는 단위 테스트를 작성해야 합니다. asPagingSourceFactory 확장 프로그램을 사용합니다. 이 확장 프로그램은 다음 데이터 유형에 사용할 수 있습니다.
List<Value>Flow<List<Value>>
테스트하려는 항목에 따라 사용할 확장 프로그램을 선택할 수 있습니다. 다음 2가지를 사용합니다.
List<Value>.asPagingSourceFactory(): 데이터에map()및insertSeparators()와 같은 정적 변환을 테스트하려는 경우Flow<List<Value>>.asPagingSourceFactory(): 지원 데이터 소스에 쓰기와 같은 데이터 업데이트가 페이징 파이프라인에 미치는 영향을 테스트하려는 경우
이러한 확장 프로그램 중 하나를 사용하려면 다음 패턴을 따르세요.
- 필요에 따라 적절한 확장 프로그램을 사용하여
PagingSourceFactory를 만듭니다. 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에서 변환을 테스트하려면 다음 스니펫과 같이 변환할 데이터를 나타내는 정적 List에 위임하는 가짜 MyRepository 인스턴스를 제공합니다.
class FakeMyRepository() : MyRepository {
private val items = (0..100).map(Any::toString)
private val pagingSourceFactory = items.asPagingSourceFactory()
// Expose as a function so a new PagingSource instance is
// created each time it is called by the Pager
fun 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()
// With the asSnapshot complete, you can now verify that the snapshot
// has the expected separators.
}
데이터 영역 테스트
데이터 영역의 구성요소에 관한 단위 테스트를 작성하여 데이터 소스에서 데이터를 적절하게 로드하는지 확인합니다. 가짜 버전의 종속 항목을 제공하여 테스트 중인 구성요소가 격리된 상태에서 제대로 작동하는지 확인합니다. 저장소 레이어에서 테스트해야 하는 기본 구성요소는 PagingSource와 RemoteMediator입니다.
PagingSource 테스트
PagingSource 구현의 단위 테스트에는 PagingSource 인스턴스를 설정하고 TestPager로 이 인스턴스에서 데이터를 로드하는 작업이 포함됩니다.
테스트를 위해 PagingSource 인스턴스를 설정하려면 생성자에 가짜 데이터를 제공하세요. 이를 통해 테스트의 데이터를 제어할 수 있습니다.
다음 예에서 RedditApi 매개변수는 서버 요청과 응답 클래스를 정의하는 Retrofit 인터페이스입니다.
가짜 버전은 인터페이스를 구현하고, 필요한 모든 함수를 재정의하며, 테스트에서 적절한 가짜 객체의 반응 방식을 구성하는 편의 메서드를 제공할 수 있습니다.
가짜가 배치되면 종속 항목을 설정하고 테스트에서 PagingSource 객체를 초기화합니다. 다음 예는 테스트 게시물 목록으로 FakeRedditApi 객체를 초기화하고 RedditPagingSource 인스턴스를 테스트하는 방법을 보여줍니다.
class SubredditPagingSourceTest {
private val mockPosts = listOf(
postFactory.createRedditPost(DEFAULT_SUBREDDIT),
postFactory.createRedditPost(DEFAULT_SUBREDDIT),
postFactory.createRedditPost(DEFAULT_SUBREDDIT)
)
private val fakeApi = FakeRedditApi().apply {
mockPosts.forEach { post -> addPost(post) }
}
@Test
fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
val pagingSource = RedditPagingSource(
fakeApi,
DEFAULT_SUBREDDIT
)
val pager = TestPager(CONFIG, pagingSource)
val result = pager.refresh() as LoadResult.Page
// Write assertions against the loaded data
assertThat(result.data)
.containsExactlyElementsIn(mockPosts)
.inOrder()
}
}
TestPager를 사용하면 다음 작업도 실행할 수 있습니다.
PagingSource에서 연속 로드를 테스트합니다.
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
PagingSource에서 오류 시나리오를 테스트합니다.
@Test
fun refresh_returnError() {
val pagingSource = RedditPagingSource(
fakeApi,
DEFAULT_SUBREDDIT
)
// Configure your fake to return errors
fakeApi.setReturnsError()
val pager = TestPager(CONFIG, source)
runTest {
source.errorNextLoad = true
val result = pager.refresh()
assertTrue(result is LoadResult.Error)
val page = pager.getLastLoadedPage()
assertThat(page).isNull()
}
}
RemoteMediator 테스트
RemoteMediator 단위 테스트의 목표는 load()
함수가 정확한
MediatorResult를 반환하는지 확인하는 것입니다.
데이터베이스에 삽입되는 데이터와 같은 부작용 테스트는 통합 테스트에 더 적합합니다.
첫 번째 단계는 RemoteMediator 구현에 필요한 종속 항목을 결정하는 것입니다. 다음 예는 Room 데이터베이스, Retrofit 인터페이스, 검색 문자열이 필요한 RemoteMediator 구현을 보여줍니다.
@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
private val db: RedditDb,
private val redditApi: RedditApi,
private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
...
}
PagingSource 테스트 섹션에 설명된 대로 Retrofit 인터페이스와 검색 문자열을 제공할 수 있습니다. Room 데이터베이스의 모의 버전을 제공하기란 매우 복잡하므로 전체 모의 버전 대신 데이터베이스의 인메모리 구현을 제공하는 것이 더 쉬울 수 있습니다. Room 데이터베이스를 만들려면 Context 객체가 필요하므로 RemoteMediator 테스트를 androidTest 디렉터리에 배치하고 AndroidJUnit4 테스트 실행기로 실행하여 테스트 애플리케이션 컨텍스트에 액세스할 수 있도록 해야 합니다. 계측 테스트에 관한 자세한 내용은 계측 단위 테스트 빌드를 참고하세요.
해체 함수를 정의하여 테스트 함수 간에 상태가 유출되지 않도록 합니다. 이렇게 하면 테스트 실행 간에 일관된 결과가 보장됩니다.
@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()
}
}
다음 단계는 load() 함수 테스트입니다. 이 예에서는 다음 세 가지 사례를 테스트합니다.
- 첫 번째 사례는
mockApi가 유효한 데이터를 반환하는 경우입니다.load()함수는MediatorResult.Success를 반환하고endOfPaginationReached속성은false여야 합니다. - 두 번째 사례는
mockApi가 성공적인 응답을 반환하지만 반환된 데이터가 비어 있는 경우입니다.load()함수는MediatorResult.Success를 반환하고endOfPaginationReached속성은true여야 합니다. - 세 번째 사례는 데이터를 가져올 때
mockApi에서 예외가 발생하는 경우입니다.load()함수는MediatorResult.Error를 반환해야 합니다.
다음 단계를 따라 첫 번째 사례를 테스트하세요.
- 반환할 게시물 데이터로
mockApi를 설정합니다. RemoteMediator객체를 초기화합니다.load()함수를 테스트합니다.
@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 }
}
두 번째 테스트에서는 mockApi가 빈 결과를 반환해야 합니다. 각 테스트 실행 후 mockApi에서 데이터를 삭제하므로 기본적으로 빈 결과가 반환됩니다.
@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 }
}
최종 테스트에서는 load() 함수가 정확하게 MediatorResult.Error를 반환하는지 확인할 수 있도록 mockApi가 예외를 발생시켜야 합니다.
@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 }
}
엔드 투 엔드 테스트
단위 테스트는 개별 Paging 구성요소가 격리된 상태로 작동하도록 보장하지만 엔드 투 엔드 테스트는 애플리케이션이 전체적으로 작동하도록 보장합니다. 이러한 테스트는 데이터 영역(PagingSource 또는 RemoteMediator), ViewModel, Compose UI가 예기치 않은 부작용 없이 원활하게 통합되는지 확인하는 데 도움이 됩니다. 테스트에는 여전히 모의 종속 항목이 필요하지만 일반적으로 대부분의 앱 코드가 포함됩니다.
이 섹션의 예는 테스트에서 네트워크를 사용하지 않도록 모의 API 종속 항목을 사용합니다. 모의 API는 일관된 테스트 데이터 집합을 반환하도록 구성되므로 테스트를 반복할 수 있습니다. 엔드 투 엔드 테스트의 경우 일반적으로 실제 네트워크 API를 가짜 API로 교체하지만 테스트의 충실도를 유지하기 위해 Paging 라이브러리가 실제 가져오기 및 로컬 데이터베이스 캐싱을 처리하도록 합니다 (RemoteMediator를 사용하는 경우).
종속 항목의 모의 버전에서 쉽게 교체할 수 있는 방식으로 코드를 작성합니다. 다음 예에서는 기본 서비스 로케이터 구현을 사용하고 모의 API로 테스트를 설정하여 Compose 화면이 페이징된 데이터를 올바르게 사용하고 표시하는지 확인합니다. 더 큰 앱에서는 종속 항목 삽입 라이브러리(예: Hilt)를 사용하면 더 복잡한 종속 항목 그래프를 관리하는 데 도움이 될 수 있습니다.
테스트 구조를 설정한 후 다음 단계는 Pager 구현에서 반환된 데이터가 정확한지 확인하는 것입니다. 한 테스트에서는 화면이 처음 로드될 때 Compose UI가 올바른 항목으로 채워지는지 확인하고, 다른 테스트에서는 UI가 사용자 상호작용에 따라 추가 데이터를 올바르게 로드하는지 확인해야 합니다.
다음 예에서 테스트는 UI가 예상되는 페이징된 데이터를 표시하는지 확인합니다.
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RedditScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val postFactory = PostFactory()
private val mockApi = MockRedditApi()
@Before
fun setup() {
// Pre-populate the mock API with test data for the default subreddit
mockApi.addPost(postFactory.createRedditPost(subreddit = "androiddev", title = "Jetpack Compose Paging"))
// Swap your real dependency injection module/Service Locator with the mock API
ServiceLocator.swap(
object : DefaultServiceLocator(useInMemoryDb = true) {
override fun getRedditApi(): RedditApi = mockApi
}
)
}
@Test
fun loadsTheDefaultResults() = runTest {
// 1. Set the Compose UI content
composeTestRule.setContent {
MyTheme {
// Assume that this composable uses `collectAsLazyPagingItems()` internally
RedditScreen(initialSubreddit = "androiddev")
}
}
// 2. Wait for the asynchronous Paging loads to complete
composeTestRule.waitUntilExactlyOneExists(
matcher = hasText("Jetpack Compose Paging"),
timeoutMillis = 5000
)
// 3. Assert that the loaded paged items are displayed correctly on screen
composeTestRule.onNodeWithText("Jetpack Compose Paging").assertIsDisplayed()
}
@Test
fun loadsNewDataBasedOnUserInput() = runTest {
// Add data for a different subreddit to the mock API
mockApi.addPost(postFactory.createRedditPost(subreddit = "compose", title = "Compose Testing"))
composeTestRule.setContent {
MyTheme {
RedditScreen(initialSubreddit = "androiddev")
}
}
// Wait for the initial load to finish
composeTestRule.waitUntilExactlyOneExists(hasText("Jetpack Compose Paging"))
// Simulate user entering a new subreddit in a text field and clicking search
composeTestRule.onNodeWithTag("SubredditInput").performTextClearance()
composeTestRule.onNodeWithTag("SubredditInput").performTextInput("compose")
composeTestRule.onNodeWithTag("SearchButton").performClick()
// Wait for the new paged data to load
composeTestRule.waitUntilExactlyOneExists(
matcher = hasText("Compose Testing"),
timeoutMillis = 5000
)
// Assert the old data is gone and the new data is displayed
composeTestRule.onNodeWithText("Jetpack Compose Paging").assertDoesNotExist()
composeTestRule.onNodeWithText("Compose Testing").assertIsDisplayed()
}
}
Flow<PagingData>는 데이터를 비동기식으로 로드하므로 어설션을 실행하기 전에 Paging
라이브러리가 초기 로드를 가져오고
collectAsLazyPagingItems에 내보낼 시간을 제공해야 합니다. 이렇게 하려면 이전 예와 같이 composeTestRule.waitUntil 또는 waitUntilExactlyOneExists를 사용합니다.
데이터가 로드된 후 onNodeWithText를 사용하여 Compose 시맨틱 트리에 직접 어설션하여 항목이 실제로 LazyColumn에 렌더링되는지 확인할 수 있습니다.
추가 리소스
콘텐츠 보기
추천 서비스
- 참고: JavaScript가 사용 중지되어 있으면 링크 텍스트가 표시됩니다.
- 네트워크 및 데이터베이스의 페이지
- Paging 3으로 이전
- 페이징 데이터 로드 및 표시