Sayfalama uygulamanızı test etme (Görüntülemeler)

Kavramlar ve Jetpack Compose uygulaması

Uygulamanızda Paging kitaplığını kullanmak, sağlam bir test stratejisiyle birlikte yapılmalıdır. PagingSource ve RemoteMediator gibi veri yükleme bileşenlerinin beklendiği gibi çalıştığından emin olmak için bunları test etmeniz gerekir. Ayrıca, sayfalandırma uygulamanızdaki tüm bileşenlerin beklenmedik yan etkiler olmadan birlikte doğru şekilde çalıştığını doğrulamak için uçtan uca testler de yazmalısınız.

Bu kılavuzda, uygulamanızın veri katmanındaki Paging kitaplığının nasıl test edileceği ve Paging uygulamanızın tamamı için uçtan uca testlerin nasıl yazılacağı açıklanmaktadır.

Veri katmanı testleri

Veri kaynaklarınızdaki verilerin uygun şekilde yüklendiğinden emin olmak için veri katmanınızdaki bileşenler için birim testleri yazın. Test edilen bileşenlerin yalıtılmış şekilde doğru çalıştığını doğrulamak için bağımlılıkların sahte sürümlerini sağlayın. Depo katmanında test etmeniz gereken bileşenlerden biri RemoteMediator.

RemoteMediator testi

RemoteMediator birim testlerinin amacı, RemoteMediator işlevinin doğru MediatorResult değerini döndürdüğünü doğrulamaktır.load() Verilerin veritabanına eklenmesi gibi yan etkilerle ilgili testler, entegrasyon testleri için daha uygundur.

İlk adım, RemoteMediator uygulamanızın hangi bağımlılıklara ihtiyaç duyduğunu belirlemektir. Aşağıdaki örnekte, Room veritabanı, Retrofit arayüzü ve arama dizesi gerektiren bir RemoteMediator uygulaması gösterilmektedir:

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

Retrofit arayüzünü ve arama dizesini, Sayfalama uygulamanızı test etme bölümündeki PagingSource testleri kısmında gösterildiği gibi sağlayabilirsiniz. Room veritabanının sahte bir sürümünü sağlamak çok karmaşık bir işlem olduğundan, tam sahte sürüm yerine veritabanının bellek içi uygulamasını sağlamak daha kolay olabilir. Room veritabanı oluşturmak için Context nesnesi gerektiğinden bu RemoteMediator testini androidTest dizinine yerleştirmeniz ve test uygulaması bağlamına erişebilmesi için AndroidJUnit4 test çalıştırıcısıyla yürütmeniz gerekir. Araçlı testler hakkında daha fazla bilgi için Araçlı birim testleri oluşturma başlıklı makaleyi inceleyin.

Durumun test işlevleri arasında sızmasını önlemek için temizleme işlevleri tanımlayın. Bu sayede test çalıştırmaları arasında tutarlı sonuçlar elde edebilirsiniz.

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

Bir sonraki adım, load() işlevini test etmektir. Bu örnekte test edilecek üç durum vardır:

  • İlk durumda mockApi geçerli veriler döndürür. load() işlevi MediatorResult.Success değerini döndürmeli ve endOfPaginationReached özelliği false olmalıdır.
  • İkinci durum, mockApi başarılı bir yanıt döndürdüğünde ancak döndürülen veriler boş olduğunda ortaya çıkar. load işlevi MediatorResult.Success değerini döndürmeli ve endOfPaginationReached özelliği true olmalıdır.
  • Üçüncü durum, mockApi verileri getirirken hata verdiğinde ortaya çıkar. load() işlevi MediatorResult.Error değerini döndürmelidir.

İlk durumu test etmek için aşağıdaki adımları uygulayın:

  1. Döndürülecek yayın verileriyle mockApi öğesini ayarlayın.
  2. RemoteMediator nesnesini başlatın.
  3. load işlevini test edin.

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

İkinci testte mockApi boş bir sonuç döndürmelidir. Her test çalıştırmasından sonra mockApi verilerini temizlediğiniz için varsayılan olarak boş bir sonuç döndürür.

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

Son testte, testin load() fonksiyonunun doğru şekilde MediatorResult.Error döndürdüğünü doğrulayabilmesi için mockApi öğesinin bir istisna oluşturması gerekir.

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

Uçtan uca testler

Birim testleri, tek tek Paging bileşenlerinin bağımsız olarak çalıştığına dair güvence verir ancak uçtan uca testler, uygulamanın bir bütün olarak çalıştığına dair daha fazla güven sağlar. Bu testler için yine bazı sahte bağımlılıklar gerekir ancak genellikle uygulama kodunuzun büyük bir bölümünü kapsar.

Bu bölümdeki örnekte, testlerde ağ kullanımını önlemek için sahte bir API bağımlılığı kullanılmaktadır. Sahte API, tutarlı bir test veri grubu döndürecek şekilde yapılandırılır. Bu da tekrarlanabilir testler yapılmasını sağlar. Her bağımlılığın ne yaptığına, çıktısının ne kadar tutarlı olduğuna ve testlerinizden ne kadar doğruluk beklediğinize bağlı olarak hangi bağımlılıkların sahte uygulamalarla değiştirileceğine karar verin.

Kodunuzu, bağımlılıklarınızın sahte sürümlerini kolayca değiştirebileceğiniz şekilde yazın. Aşağıdaki örnekte, bağımlılıkları gerektiği gibi sağlamak ve değiştirmek için temel bir hizmet bulucu uygulaması kullanılmaktadır. Daha büyük uygulamalarda, Hilt gibi bir bağımlılık yerleştirme kitaplığı kullanmak daha karmaşık bağımlılık grafiklerini yönetmeye yardımcı olabilir.

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

Test yapısını ayarladıktan sonraki adım, Pager uygulamasının döndürdüğü verilerin doğru olduğunu doğrulamaktır. Bir test, sayfa ilk yüklendiğinde Pager nesnesinin varsayılan verileri yüklemesini sağlamalıdır. Diğer bir test ise Pager nesnesinin, kullanıcı girişine göre ek verileri doğru şekilde yüklediğini doğrulamalıdır.

Aşağıdaki örnekte, kullanıcı farklı bir alt dizin girerek arama yaptığında test, Pager nesnesinin API'den döndürülen doğru sayıda öğeyle RecyclerView.Adapter öğesini güncellediğini doğrular.

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

Enstrümanlı testler, verilerin kullanıcı arayüzünde doğru şekilde görüntülendiğini doğrulamalıdır. Bunu, RecyclerView.Adapter içinde doğru sayıda öğe olduğunu doğrulayarak veya tek tek satır görünümlerini inceleyip verilerin doğru şekilde biçimlendirildiğini doğrulayarak yapın.