Paging 구현 테스트 (Views)

개념 및 Jetpack Compose 구현

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

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

데이터 영역 테스트

데이터 영역의 구성요소에 관한 단위 테스트를 작성하여 데이터 소스에서 데이터를 적절하게 로드하는지 확인합니다. 가짜 버전의 종속 항목을 제공하여 테스트 중인 구성요소가 격리된 상태에서 제대로 작동하는지 확인합니다. 저장소 레이어에서 테스트해야 하는 구성요소 중 하나는 RemoteMediator입니다.

RemoteMediator 테스트

RemoteMediator 단위 테스트의 목표는 load() 함수가 정확한 MediatorResult를 반환하는지 확인하는 것입니다. 데이터베이스에 삽입되는 데이터와 같은 부작용 테스트는 통합 테스트에 더 적합합니다.

첫 번째 단계는 RemoteMediator 구현에 필요한 종속 항목을 결정하는 것입니다. 다음 예는 Room 데이터베이스, Retrofit 인터페이스, 검색 문자열이 필요한 RemoteMediator 구현을 보여줍니다.

Java (RxJava)

public class PageKeyedRemoteMediator
  extends RxRemoteMediator<Integer, RedditPost> {

  @NonNull
  private RedditDb db;
  @NonNull
  private RedditPostDao postDao;
  @NonNull
  private SubredditRemoteKeyDao remoteKeyDao;
  @NonNull
  private RedditApi redditApi;
  @NonNull
  private String subredditName;

  public PageKeyedRemoteMediator(
    @NonNull RedditDb db,
    @NonNull RedditApi redditApi,
    @NonNull String subredditName
    ) {
      this.db = db;
      this.postDao = db.posts();
      this.remoteKeyDao = db.remoteKeys();
      this.redditApi = redditApi;
      this.subredditName = subredditName;
      ...
    }
  }

Java (Guava/LiveData)

public class PageKeyedRemoteMediator
  extends ListenableFutureRemoteMediator<Integer, RedditPost> {

  @NonNull
  private RedditDb db;
  @NonNull
  private RedditPostDao postDao;
  @NonNull
  private SubredditRemoteKeyDao remoteKeyDao;
  @NonNull
  private RedditApi redditApi;
  @NonNull
  private String subredditName;
  @NonNull
  private Executor bgExecutor;

  public PageKeyedRemoteMediator(
    @NonNull RedditDb db,
    @NonNull RedditApi redditApi,
    @NonNull String subredditName,
    @NonNull Executor bgExecutor
  ) {
    this.db = db;
    this.postDao = db.posts();
    this.remoteKeyDao = db.remoteKeys();
    this.redditApi = redditApi;
    this.subredditName = subredditName;
    this.bgExecutor = bgExecutor;
    ...
  }
}

페이징 구현 테스트PagingSource 테스트 섹션에 설명된 대로 Retrofit 인터페이스와 검색 문자열을 제공할 수 있습니다. Room 데이터베이스의 모의 버전을 제공하기란 매우 복잡하므로 전체 모의 버전 대신 데이터베이스의 인메모리 구현을 제공하는 것이 더 쉬울 수 있습니다. Room 데이터베이스를 만들려면 Context 객체가 필요하므로 RemoteMediator 테스트를 androidTest 디렉터리에 배치하고 AndroidJUnit4 테스트 실행기로 실행하여 테스트 애플리케이션 컨텍스트에 액세스할 수 있도록 해야 합니다. 계측 테스트에 관한 자세한 내용은 계측 단위 테스트 빌드를 참고하세요.

해체 함수를 정의하여 테스트 함수 간에 상태가 유출되지 않도록 합니다. 이렇게 하면 테스트 실행 간에 일관된 결과가 보장됩니다.

Java (RxJava)

@RunWith(AndroidJUnit4.class)
public class PageKeyedRemoteMediatorTest {
  static PostFactory postFactory = new PostFactory();
  static List<RedditPost> mockPosts = new ArrayList<>();
  static MockRedditApi mockApi = new MockRedditApi();
  private RedditDb mockDb = RedditDb.Companion.create(
    ApplicationProvider.getApplicationContext(),
    true
  );

  static {
    for (int i=0; i<3; i++) {
      RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT);
      mockPosts.add(post);
    }
  }

  @After
  public void tearDown() {
    mockDb.clearAllTables();
    // Clear the failure message after each test run.
    mockApi.setFailureMsg(null);
    // Clear out posts after each test run.
    mockApi.clearPosts();
  }
}

Java (Guava/LiveData)

@RunWith(AndroidJUnit4.class)
public class PageKeyedRemoteMediatorTest {
  static PostFactory postFactory = new PostFactory();
  static List<RedditPost> mockPosts = new ArrayList<>();
  static MockRedditApi mockApi = new MockRedditApi();

  private RedditDb mockDb = RedditDb.Companion.create(
    ApplicationProvider.getApplicationContext(),
    true
  );

  static {
    for (int i=0; i<3; i++) {
      RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT);
      mockPosts.add(post);
    }
  }

  @After
  public void tearDown() {
    mockDb.clearAllTables();
    // Clear the failure message after each test run.
    mockApi.setFailureMsg(null);
    // Clear out posts after each test run.
    mockApi.clearPosts();
  }
}

다음 단계는 load() 함수 테스트입니다. 이 예에서는 다음 세 가지 사례를 테스트합니다.

  • 첫 번째 사례는 mockApi가 유효한 데이터를 반환하는 경우입니다. load() 함수는 MediatorResult.Success를 반환하고 endOfPaginationReached 속성은 false여야 합니다.
  • 두 번째 사례는 mockApi가 성공적인 응답을 반환하지만 반환된 데이터가 비어 있는 경우입니다. load 함수는 MediatorResult.Success를 반환하고 endOfPaginationReached 속성은 true여야 합니다.
  • 세 번째 사례는 데이터를 가져올 때 mockApi에서 예외가 발생하는 경우입니다. load() 함수는 MediatorResult.Error를 반환해야 합니다.

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

  1. 반환할 게시물 데이터로 mockApi를 설정합니다.
  2. RemoteMediator 객체를 초기화합니다.
  3. load 함수를 테스트합니다.

Java (RxJava)

@Test
public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent()
  throws InterruptedException {

  // Add mock results for the API to return.
  for (RedditPost post: mockPosts) {
    mockApi.addPost(post);
  }

  PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  );
  PagingState<Integer, RedditPost> pagingState = new PagingState<>(
    new ArrayList(),
    null,
    new PagingConfig(10),
    10
  );
  remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
    .test()
    .await()
    .assertValueCount(1)
    .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success &&
      ((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == false);
}

Java (Guava/LiveData)

@Test
public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent()
  throws InterruptedException, ExecutionException {

  // Add mock results for the API to return.
  for (RedditPost post: mockPosts) {
    mockApi.addPost(post);
  }

  PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT,
    new CurrentThreadExecutor()
  );
  PagingState<Integer, RedditPost> pagingState = new PagingState<>(
    new ArrayList(),
    null,
    new PagingConfig(10),
    10
  );

  RemoteMediator.MediatorResult result =
    remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();

  assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class));
  assertFalse(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached());
}

두 번째 테스트에서는 mockApi가 빈 결과를 반환해야 합니다. 각 테스트 실행 후 mockApi에서 데이터를 삭제하므로 기본적으로 빈 결과가 반환됩니다.

Java (RxJava)

@Test
public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData()
  throws InterruptedException() {

  // To test endOfPaginationReached, don't set up the mockApi to return post
  // data here.
  PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  );
  PagingState<Integer, RedditPost> pagingState = new PagingState<>(
    new ArrayList(),
    null,
    new PagingConfig(10),
    10
  );
  remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
    .test()
    .await()
    .assertValueCount(1)
    .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success &&
      ((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == true);
}

Java (Guava/LiveData)

@Test
public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData()
  throws InterruptedException, ExecutionException {

  // To test endOfPaginationReached, don't set up the mockApi to return post
  // data here.
  PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT,
    new CurrentThreadExecutor()
  );
  PagingState<Integer, RedditPost> pagingState = new PagingState<>(
    new ArrayList(),
    null,
    new PagingConfig(10),
    10
  );

  RemoteMediator.MediatorResult result =
    remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();

  assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class));
  assertTrue(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached());
}

최종 테스트에서는 load() 함수가 정확하게 MediatorResult.Error를 반환하는지 확인할 수 있도록 mockApi가 예외를 발생시켜야 합니다.

Java (RxJava)

@Test
public void refreshLoadReturnsErrorResultWhenErrorOccurs()
  throws InterruptedException {

  // Set up failure message to throw exception from the mock API.
  mockApi.setFailureMsg("Throw test failure");
  PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  );
  PagingState<Integer, RedditPost> pagingState = new PagingState<>(
    new ArrayList(),
    null,
    new PagingConfig(10),
    10
  );
  remoteMediator.loadSingle(LoadType.REFRESH, pagingState)
    .test()
    .await()
    .assertValueCount(1)
    .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Error);
}

Java (Guava/LiveData)

@Test
public void refreshLoadReturnsErrorResultWhenErrorOccurs()
  throws InterruptedException, ExecutionException {

  // Set up failure message to throw exception from the mock API.
  mockApi.setFailureMsg("Throw test failure");
  PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT,
    new CurrentThreadExecutor()
  );
  PagingState<Integer, RedditPost> pagingState = new PagingState<>(
    new ArrayList(),
    null,
    new PagingConfig(10),
    10
  );
  RemoteMediator.MediatorResult result =
    remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get();

  assertThat(result, instanceOf(RemoteMediator.MediatorResult.Error.class));
}

엔드 투 엔드 테스트

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

public class RedditActivityTest {

  public static final String TEST_SUBREDDIT = "test";

  private static PostFactory postFactory = new PostFactory();
  private static MockRedditApi mockApi = new MockRedditApi();

  static {
    mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT));
    mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
    mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
  }

  @Before
  public void setup() {
    Application app = ApplicationProvider.getApplicationContext();
    // Use a controlled service locator with a mock API.
    ServiceLocator.Companion.swap(
      new DefaultServiceLocator(app, true) {
        @NotNull
        @Override
        public RedditApi getRedditApi() {
          return mockApi;
        }
      }
    );
  }
}

Java (Guava/LiveData)

public class RedditActivityTest {

  public static final String TEST_SUBREDDIT = "test";

  private static PostFactory postFactory = new PostFactory();
  private static MockRedditApi mockApi = new MockRedditApi();

  static {
    mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT));
    mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
    mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT));
  }

  @Before
  public void setup() {
    Application app = ApplicationProvider.getApplicationContext();
    // Use a controlled service locator with a mock API.
    ServiceLocator.Companion.swap(
      new DefaultServiceLocator(app, true) {
        @NotNull
        @Override
        public RedditApi getRedditApi() {
          return mockApi;
        }
      }
    );
  }
}

테스트 구조를 설정한 후 다음 단계는 Pager 구현에서 반환된 데이터가 정확한지 확인하는 것입니다. 한 테스트에서는 페이지가 처음 로드될 때 Pager 객체가 기본 데이터를 로드하는지 확인하고, 다른 테스트에서는 Pager 객체가 사용자 입력에 따라 추가 데이터를 정확하게 로드하는지 확인해야 합니다.

다음 예의 테스트에서는 사용자가 검색할 다른 하위 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 (RxJava)

@Test
public void loadsTheDefaultResults() {
  ActivityScenario.launch(RedditActivity.class);

  onView(withId(R.id.list)).check((view, noViewFoundException) -> {
    if (noViewFoundException != null) {
      throw noViewFoundException;
    }

    RecyclerView recyclerView = (RecyclerView) view;
    assertEquals(1, recyclerView.getAdapter().getItemCount());
  });
}

@Test
// Verify that the default data is swapped out when the user searches for a
// different subreddit.
public void loadsTheTestResultsWhenSearchingForSubreddit() {
  ActivityScenario.launch(RedditActivity.class);

  onView(withId(R.id.list)).check((view, noViewFoundException) -> {
    if (noViewFoundException != null) {
      throw noViewFoundException;
    }

    RecyclerView recyclerView = (RecyclerView) view;
    // Verify that it loads the default data first.
    assertEquals(1, recyclerView.getAdapter().getItemCount());
  });

  // Search for test subreddit instead of default to trigger new data load.
  onView(withId(R.id.input)).perform(
    replaceText(TEST_SUBREDDIT),
    pressKey(KeyEvent.KEYCODE_ENTER)
  );

  onView(withId(R.id.list)).check((view, noViewFoundException) -> {
    if (noViewFoundException != null) {
      throw noViewFoundException;
    }

    RecyclerView recyclerView = (RecyclerView) view;
    assertEquals(2, recyclerView.getAdapter().getItemCount());
  });
}

Java (Guava/LiveData)

@Test
public void loadsTheDefaultResults() {
  ActivityScenario.launch(RedditActivity.class);

  onView(withId(R.id.list)).check((view, noViewFoundException) -> {
    if (noViewFoundException != null) {
      throw noViewFoundException;
    }

    RecyclerView recyclerView = (RecyclerView) view;
    assertEquals(1, recyclerView.getAdapter().getItemCount());
  });
}

@Test
// Verify that the default data is swapped out when the user searches for a
// different subreddit.
public void loadsTheTestResultsWhenSearchingForSubreddit() {
  ActivityScenario.launch(RedditActivity.class);

  onView(withId(R.id.list)).check((view, noViewFoundException) -> {
    if (noViewFoundException != null) {
      throw noViewFoundException;
    }

    RecyclerView recyclerView = (RecyclerView) view;
    // Verify that it loads the default data first.
    assertEquals(1, recyclerView.getAdapter().getItemCount());
  });

  // Search for test subreddit instead of default to trigger new data load.
  onView(withId(R.id.input)).perform(
    replaceText(TEST_SUBREDDIT),
    pressKey(KeyEvent.KEYCODE_ENTER)
  );

  onView(withId(R.id.list)).check((view, noViewFoundException) -> {
    if (noViewFoundException != null) {
      throw noViewFoundException;
    }

    RecyclerView recyclerView = (RecyclerView) view;
    assertEquals(2, recyclerView.getAdapter().getItemCount());
  });
}

계측 테스트에서는 데이터가 UI에 정확하게 표시되는지 확인해야 합니다. 이렇게 하려면 정확한 항목 수가 RecyclerView.Adapter에 있는지 확인하거나 개별 행 뷰를 반복하고 데이터 형식이 정확한지 확인하면 됩니다.