Paging 구현 테스트

앱에서 Paging 라이브러리를 구현하는 작업에는 강력한 테스트 전략이 필요합니다. 데이터 로드 구성요소(예: PagingSource, RemoteMediator)를 테스트하여 제대로 작동하는지 확인해야 합니다. 또한 엔드 투 엔드 테스트를 작성하여 Paging 구현의 모든 구성요소가 예기치 않은 부작용 없이 제대로 작동하는지 확인해야 합니다.

이 가이드에서는 앱의 다양한 아키텍처 레이어에서 Paging 라이브러리를 테스트하는 방법과 전체 Paging 구현을 위한 엔드 투 엔드 테스트를 작성하는 방법을 설명합니다.

UI 레이어 테스트

Paging 라이브러리를 사용하여 가져온 데이터는 UI에서 Flow<PagingData<Value>>로 사용됩니다. UI의 데이터가 예상과 같은지 확인하는 테스트를 작성하려면 paging-testing 종속 항목을 포함합니다. Flow<PagingData<Value>>asSnapshot() 확장 프로그램이 포함되어 있습니다. 스크롤 상호작용을 모의 처리할 수 있는 람다 수신기에 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를 만듭니다.
  • Repositoryfake에서 반환된 PagingSourceFactory를 사용합니다.
  • 해당 RepositoryViewModel에 전달합니다.

그런 다음 이전 섹션에서 다룬 것처럼 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()

    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()

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected separators.
}

데이터 영역 테스트

데이터 영역의 구성요소에 관한 단위 테스트를 작성하여 데이터 소스에서 데이터를 적절하게 로드하는지 확인합니다. 가짜 버전의 종속 항목을 제공하여 테스트 중인 구성요소가 격리된 상태에서 제대로 작동하는지 확인합니다. 저장소 레이어에서 테스트해야 하는 기본 구성요소는 PagingSourceRemoteMediator입니다. 다음 섹션의 예는 네트워크를 사용한 페이징 샘플을 기반으로 합니다.

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 구현을 보여줍니다.

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를 반환해야 합니다.

다음 단계를 따라 첫 번째 사례를 테스트하세요.

  1. 반환할 게시물 데이터로 mockApi를 설정합니다.
  2. RemoteMediator 객체를 초기화합니다.
  3. 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());
}

최종 테스트에서는 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 }
}

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));
}

엔드 투 엔드 테스트

단위 테스트는 개별 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
      }
    )
  }
}

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 객체가 사용자 입력에 따라 추가 데이터를 정확하게 로드하는지 확인해야 합니다. 다음 예의 테스트에서는 사용자가 검색할 다른 하위 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)
  }
}

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());
  });
}

자바

@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에 있는지 확인하거나 개별 행 뷰를 반복하고 데이터 형식이 정확한지 확인하면 됩니다.