diff --git a/app/build.gradle b/app/build.gradle index 8fc180bc..1e79cecc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -107,4 +107,6 @@ dependencies { testImplementation libs.androidx.core.testing testImplementation libs.mockito.core testImplementation libs.mockito.inline -} \ No newline at end of file + testImplementation libs.robolectric +} + diff --git a/app/src/test/java/com/d4rk/androidtutorials/java/data/source/remote/DefaultHomeRemoteDataSourceTest.java b/app/src/test/java/com/d4rk/androidtutorials/java/data/source/remote/DefaultHomeRemoteDataSourceTest.java new file mode 100644 index 00000000..6577711b --- /dev/null +++ b/app/src/test/java/com/d4rk/androidtutorials/java/data/source/remote/DefaultHomeRemoteDataSourceTest.java @@ -0,0 +1,218 @@ +package com.d4rk.androidtutorials.java.data.source.remote; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.os.Looper; + +import com.android.volley.Request; +import com.android.volley.RequestQueue; +import com.android.volley.Response; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.JsonObjectRequest; +import com.d4rk.androidtutorials.java.data.model.PromotedApp; +import com.d4rk.androidtutorials.java.data.source.DefaultHomeRemoteDataSource; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +@RunWith(RobolectricTestRunner.class) +public class DefaultHomeRemoteDataSourceTest { + + private RequestQueue requestQueue; + private DefaultHomeRemoteDataSource dataSource; + private AtomicReference lastRequest; + + @Before + public void setUp() { + requestQueue = mock(RequestQueue.class); + lastRequest = new AtomicReference<>(); + when(requestQueue.add(any(Request.class))).thenAnswer(invocation -> { + Request request = invocation.getArgument(0); + if (request instanceof JsonObjectRequest) { + lastRequest.set((JsonObjectRequest) request); + } + return request; + }); + dataSource = new DefaultHomeRemoteDataSource(requestQueue, "https://example.com/api"); + } + + @Test + public void fetchPromotedApps_withValidResponse_deliversParsedAppsOnMainThread() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> resultRef = new AtomicReference<>(); + AtomicReference threadRef = new AtomicReference<>(); + + dataSource.fetchPromotedApps(apps -> { + resultRef.set(apps); + threadRef.set(Thread.currentThread()); + latch.countDown(); + }); + + JsonObjectRequest request = captureLastRequest(); + deliverSuccess(request, createValidResponse()); + + assertTrue("Callback not invoked", waitForCallback(latch)); + + List apps = resultRef.get(); + assertNotNull(apps); + assertEquals(1, apps.size()); + + PromotedApp promotedApp = apps.get(0); + assertEquals("Example App", promotedApp.name()); + assertEquals("com.example.promoted", promotedApp.packageName()); + assertEquals("https://example.com/icon.png", promotedApp.iconUrl()); + + assertEquals(Looper.getMainLooper().getThread(), threadRef.get()); + } + + @Test + public void fetchPromotedApps_withMalformedResponse_returnsEmptyListOnMainThread() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> resultRef = new AtomicReference<>(); + AtomicReference threadRef = new AtomicReference<>(); + + dataSource.fetchPromotedApps(apps -> { + resultRef.set(apps); + threadRef.set(Thread.currentThread()); + latch.countDown(); + }); + + JsonObjectRequest request = captureLastRequest(); + deliverSuccess(request, createMalformedResponse()); + + assertTrue("Callback not invoked", waitForCallback(latch)); + + List apps = resultRef.get(); + assertNotNull(apps); + assertTrue(apps.isEmpty()); + assertEquals(Looper.getMainLooper().getThread(), threadRef.get()); + } + + @Test + public void fetchPromotedApps_onNetworkError_returnsEmptyListOnMainThread() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> resultRef = new AtomicReference<>(); + AtomicReference threadRef = new AtomicReference<>(); + + dataSource.fetchPromotedApps(apps -> { + resultRef.set(apps); + threadRef.set(Thread.currentThread()); + latch.countDown(); + }); + + JsonObjectRequest request = captureLastRequest(); + deliverError(request, new VolleyError("network")); + + assertTrue("Callback not invoked", waitForCallback(latch)); + + List apps = resultRef.get(); + assertNotNull(apps); + assertTrue(apps.isEmpty()); + assertEquals(Looper.getMainLooper().getThread(), threadRef.get()); + } + + @Test + public void parseResponse_skipsTutorialPackages() throws Exception { + JSONObject response = createValidResponse(); + Method parseResponse = DefaultHomeRemoteDataSource.class + .getDeclaredMethod("parseResponse", JSONObject.class); + parseResponse.setAccessible(true); + + @SuppressWarnings("unchecked") + List apps = (List) parseResponse.invoke(dataSource, response); + assertEquals(1, apps.size()); + assertEquals("com.example.promoted", apps.get(0).packageName()); + } + + private JsonObjectRequest captureLastRequest() { + JsonObjectRequest request = lastRequest.get(); + assertNotNull("Expected a JsonObjectRequest to be enqueued", request); + return request; + } + + private void deliverSuccess(JsonObjectRequest request, JSONObject response) throws Exception { + @SuppressWarnings("unchecked") + Response.Listener listener = (Response.Listener) getFieldValue(request, "mListener"); + assertNotNull(listener); + listener.onResponse(response); + } + + private void deliverError(JsonObjectRequest request, VolleyError error) throws Exception { + Response.ErrorListener listener = (Response.ErrorListener) getFieldValue(request, "mErrorListener"); + assertNotNull(listener); + listener.onErrorResponse(error); + } + + private Object getFieldValue(Object target, String fieldName) throws Exception { + Class type = target.getClass(); + while (type != null) { + try { + Field field = type.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new NoSuchFieldException(fieldName); + } + + private boolean waitForCallback(CountDownLatch latch) throws InterruptedException { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(1); + while (latch.getCount() > 0 && System.currentTimeMillis() < deadline) { + Shadows.shadowOf(Looper.getMainLooper()).idle(); + Thread.sleep(1); + } + return latch.getCount() == 0; + } + + private static JSONObject createValidResponse() throws JSONException { + JSONObject first = new JSONObject(); + first.put("name", "Example App"); + first.put("packageName", "com.example.promoted"); + first.put("iconLogo", "https://example.com/icon.png"); + + JSONObject blocked = new JSONObject(); + blocked.put("name", "Tutorial App"); + blocked.put("packageName", "com.d4rk.androidtutorials"); + blocked.put("iconLogo", "https://example.com/tutorial.png"); + + JSONArray apps = new JSONArray(); + apps.put(first); + apps.put(blocked); + + JSONObject data = new JSONObject(); + data.put("apps", apps); + + JSONObject response = new JSONObject(); + response.put("data", data); + return response; + } + + private static JSONObject createMalformedResponse() throws JSONException { + JSONObject data = new JSONObject(); + data.put("invalid", new JSONArray()); + + JSONObject response = new JSONObject(); + response.put("data", data); + return response; + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f62cc9df..714c1c36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ hilt = "2.57.1" room = "2.8.0" glide = "5.0.4" retrofit = "3.0.0" +robolectric = "4.13.2" [libraries] aboutlibraries = { module = "com.mikepenz:aboutlibraries", version.ref = "aboutlibraries" } @@ -73,3 +74,4 @@ glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } retrofit2 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit2-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }