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
mockApigeçerli veriler döndürür.load()işleviMediatorResult.Successdeğerini döndürmeli veendOfPaginationReachedözelliğifalseolmalıdır. - İkinci durum,
mockApibaşarılı bir yanıt döndürdüğünde ancak döndürülen veriler boş olduğunda ortaya çıkar.loadişleviMediatorResult.Successdeğerini döndürmeli veendOfPaginationReachedözelliğitrueolmalıdır. - Üçüncü durum,
mockApiverileri getirirken hata verdiğinde ortaya çıkar.load()işleviMediatorResult.Errordeğerini döndürmelidir.
İlk durumu test etmek için aşağıdaki adımları uygulayın:
- Döndürülecek yayın verileriyle
mockApiöğesini ayarlayın. RemoteMediatornesnesini başlatın.loadiş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.