सिद्धांत और Jetpack Compose को लागू करना
अपने ऐप्लिकेशन में Paging लाइब्रेरी को लागू करने के साथ-साथ, टेस्टिंग की मज़बूत रणनीति का इस्तेमाल करना चाहिए. आपको डेटा लोड करने वाले कॉम्पोनेंट की जांच करनी चाहिए. जैसे, PagingSource और RemoteMediator. इससे यह पक्का किया जा सकता है कि वे उम्मीद के मुताबिक काम कर रहे हैं या नहीं. आपको एंड-टू-एंड टेस्ट भी लिखने चाहिए, ताकि यह पुष्टि की जा सके कि पेज पर नंबर डालने की सुविधा लागू करने के दौरान, सभी कॉम्पोनेंट एक साथ सही तरीके से काम कर रहे हैं. साथ ही, यह भी पक्का किया जा सके कि कोई अनचाहा साइड इफ़ेक्ट न हो.
इस गाइड में, आपके ऐप्लिकेशन के डेटा लेयर में Paging लाइब्रेरी को टेस्ट करने का तरीका बताया गया है. साथ ही, इसमें Paging लाइब्रेरी के पूरे प्रोसेस को लागू करने के लिए, शुरू से आखिर तक टेस्ट लिखने का तरीका भी बताया गया है.
डेटा लेयर के टेस्ट
अपने डेटा लेयर में मौजूद कॉम्पोनेंट के लिए यूनिट टेस्ट लिखें. इससे यह पक्का किया जा सकेगा कि वे आपके डेटा सोर्स से डेटा को सही तरीके से लोड करते हैं. डिपेंडेंसी के नकली वर्शन उपलब्ध कराएं, ताकि यह पुष्टि की जा सके कि टेस्ट किए जा रहे कॉम्पोनेंट, आइसोलेशन में सही तरीके से काम करते हैं. आपको रिपॉज़िटरी लेयर में RemoteMediator की जांच करनी होगी.
RemoteMediator टेस्ट
RemoteMediator यूनिट टेस्ट का मकसद यह पुष्टि करना है कि RemoteMediator फ़ंक्शन, सही MediatorResult दिखाता है.load() डेटाबेस में डेटा डालने जैसे साइड इफ़ेक्ट की जांच करने के लिए, इंटिग्रेशन टेस्ट बेहतर होते हैं.
पहला चरण यह तय करना है कि आपके RemoteMediator
लागू करने के लिए किन डिपेंडेंसी की ज़रूरत है. यहां 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दिखाना चाहिए.
पहले मामले की जांच करने के लिए, यह तरीका अपनाएं:
- डेटा वापस पाने के लिए, पोस्ट डेटा के साथ
mockApiसेट अप करें. RemoteMediatorऑब्जेक्ट को शुरू करें.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());
}
आखिरी टेस्ट के लिए, mockApi को एक अपवाद जनरेट करना होगा, ताकि टेस्ट यह पुष्टि कर सके कि load() फ़ंक्शन, MediatorResult.Error को सही तरीके से दिखाता है.
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));
}
शुरू से आखिर तक के टेस्ट
यूनिट टेस्ट से यह पक्का किया जाता है कि अलग-अलग पेजिंग कॉम्पोनेंट, आइसोलेशन में काम करते हैं. हालांकि, एंड-टू-एंड टेस्ट से यह पक्का किया जाता है कि ऐप्लिकेशन पूरी तरह से काम करता है. इन टेस्ट के लिए, अब भी कुछ मॉक डिपेंडेंसी की ज़रूरत होगी. हालांकि, आम तौर पर ये आपके ऐप्लिकेशन कोड के ज़्यादातर हिस्से को कवर करेंगे.
इस सेक्शन में दिए गए उदाहरण में, एपीआई की मॉक डिपेंडेंसी का इस्तेमाल किया गया है, ताकि टेस्ट में नेटवर्क का इस्तेमाल न करना पड़े. मॉक एपीआई को टेस्ट डेटा का एक जैसा सेट दिखाने के लिए कॉन्फ़िगर किया जाता है. इससे टेस्ट को बार-बार दोहराया जा सकता है. यह तय करें कि आपको किन डिपेंडेंसी को मॉक इंप्लीमेंटेशन के लिए स्वैप करना है. इसके लिए, यह देखें कि हर डिपेंडेंसी क्या करती है, उसका आउटपुट कितना सटीक है, और आपको अपने टेस्ट से कितनी सटीक जानकारी चाहिए.
अपने कोड को इस तरह से लिखो कि आप अपनी डिपेंडेंसी के मॉक वर्शन को आसानी से बदल सकें. यहां दिए गए उदाहरण में, ज़रूरत के मुताबिक डिपेंडेंसी देने और बदलने के लिए, सर्विस लोकेटर लागू करने के बुनियादी तरीके का इस्तेमाल किया गया है. बड़े ऐप्लिकेशन में, 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 ऑब्जेक्ट, उपयोगकर्ता के इनपुट के आधार पर अतिरिक्त डेटा को सही तरीके से लोड करता है.
यहां दिए गए उदाहरण में, टेस्ट से यह पुष्टि की जाती है कि जब उपयोगकर्ता खोज के लिए कोई दूसरा सबरेडिट डालता है, तब Pager ऑब्जेक्ट, एपीआई से मिले आइटम की सही संख्या के साथ 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());
});
}
इंस्ट्रुमेंटेड टेस्ट से यह पुष्टि की जानी चाहिए कि यूज़र इंटरफ़ेस (यूआई) में डेटा सही तरीके से दिख रहा है. इसके लिए, यह पुष्टि करें कि RecyclerView.Adapter में सही संख्या में आइटम मौजूद हैं. इसके अलावा, हर लाइन के व्यू पर जाकर यह पुष्टि करें कि डेटा सही तरीके से फ़ॉर्मैट किया गया है.