diff --git a/app/build.gradle b/app/build.gradle index 1ff60e1..3f80de6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { versionCode 1 versionName "1.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner "com.codechallenge.doctorscatalog.MainTestRunner" } buildTypes { @@ -44,6 +44,10 @@ android { } } +kapt { + correctErrorTypes true +} + dependencies { // androidx implementation 'androidx.appcompat:appcompat:1.2.0' @@ -71,12 +75,33 @@ dependencies { implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" //image downloading tools implementation "io.coil-kt:coil:$coil_version" + //correct instantiating of MockWebServer + implementation "com.squareup.okhttp3:logging-interceptor:$mockwebserver_version" // unit tests testImplementation 'junit:junit:4.13' // integration tests androidTestImplementation 'androidx.test.ext:junit:1.1.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' - + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" + androidTestImplementation "androidx.test.espresso:espresso-intents:$espresso_version" + // manipulation with recyclerview + androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version" + // allow to wait conditions + androidTestImplementation 'org.awaitility:awaitility-kotlin:3.1.1' + // allow managing UI device conditions + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' + // dependency injection test tool + androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" + // mock service for kotlin + androidTestImplementation "org.mockito:mockito-core:$mockito_version" + androidTestImplementation "org.mockito:mockito-android:$mockito_version" + // network mock + androidTestImplementation "com.squareup.okhttp3:mockwebserver:$mockwebserver_version" + // grant permissions rule + androidTestImplementation "androidx.test:rules:$androidx_test_version" + androidTestImplementation "androidx.test:runner:$androidx_test_version" + // navigation test + androidTestImplementation "androidx.navigation:navigation-testing:$navigation_version" } \ No newline at end of file diff --git a/app/src/androidTest/assets/doctors-CvQD7gEAAKjcb.json b/app/src/androidTest/assets/doctors-CvQD7gEAAKjcb.json index 4561065..230f8d2 100644 --- a/app/src/androidTest/assets/doctors-CvQD7gEAAKjcb.json +++ b/app/src/androidTest/assets/doctors-CvQD7gEAAKjcb.json @@ -548,5 +548,5 @@ "translation": null } ], - "lastKey": "dJiBHLZjYWs6iSqb" + "lastKey": null } \ No newline at end of file diff --git a/app/src/androidTest/assets/empty.json b/app/src/androidTest/assets/empty.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/app/src/androidTest/assets/empty.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/app/src/androidTest/assets/malformed_doctors.json b/app/src/androidTest/assets/malformed_doctors.json new file mode 100644 index 0000000..11f4bc2 --- /dev/null +++ b/app/src/androidTest/assets/malformed_doctors.json @@ -0,0 +1,34 @@ +{ + "doctors": + { + "id": 123456789, + "name": "Dr. med. Mario Voss", + "photoId": null, + "rating": "2.5", + "address": "Friedrichstraße 115, 10117 Berlin, Germany", + "lat": "52.526779", + "lng": "13.387201", + "highlighted": false, + "reviewCount": 5, + "specialityIds": [ + 1 + ], + "source": "google", + "phoneNumber": "+29 30 28391555", + "email": null, + "website": "http://www.vivy-doc.de/", + "openingHours": [ + "D1T08:00/D1T12:00", + "D1T15:00/D1T18:00", + "D2T08:00/D2T12:00", + "D3T08:00/D3T12:00", + "D2T08:00/D2T12:00", + "D2T15:00/D2T18:00", + "D5T08:00/D5T12:00" + ], + "integration": null, + "translation": null + } + , + "lastKey": "CvQD7gEAAKjcb" +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/ClickOnItems.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/ClickOnItems.kt new file mode 100644 index 0000000..b58dd30 --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/ClickOnItems.kt @@ -0,0 +1,104 @@ +package com.codechallenge.doctorscatalog + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import com.codechallenge.doctorscatalog.di.NetworkModule +import com.codechallenge.doctorscatalog.ui.main.ItemViewHolder +import com.codechallenge.doctorscatalog.utils.getStringFrom +import com.codechallenge.doctorscatalog.utils.waitUntilViewWithId +import com.codechallenge.doctorscatalog.utils.waitUntilViewWithText +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@UninstallModules(NetworkModule::class) +@HiltAndroidTest +class ClickOnItems { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val activityTestRule = ActivityTestRule(MainActivity::class.java, true, false) + + private lateinit var mockServer: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + mockServer = MockWebServer() + mockServer.start(8080) + } + + @ExperimentalCoroutinesApi + @InternalCoroutinesApi + @Test + fun clickOnVivyDoctors_shouldOpenDetails() { + setDispatcher("doctors.json", 200) + activityTestRule.launchActivity(null) + + waitUntilViewWithId(R.id.doctors_list_rv, 5, isDisplayed()) + waitUntilViewWithText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen", 5, isDisplayed()) + onView(withId(R.id.doctors_list_rv)).perform(RecyclerViewActions.actionOnItemAtPosition(3, click())) + waitUntilViewWithId(R.id.name_tv, 5, isDisplayed()) + waitUntilViewWithText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen", 5, isDisplayed()) + } + + @ExperimentalCoroutinesApi + @InternalCoroutinesApi + @LargeTest + @Test + fun clickOnRecentDoctors_shouldOpenDetails() { + setDispatcher("doctors.json", 200) + activityTestRule.launchActivity(null) + + waitUntilViewWithId(R.id.doctors_list_rv, 5, isDisplayed()) + waitUntilViewWithText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen", 15, isDisplayed()) + onView(withId(R.id.doctors_list_rv)).perform(RecyclerViewActions.actionOnItemAtPosition(3, click())) + waitUntilViewWithId(R.id.name_tv, 15, isDisplayed()) + waitUntilViewWithText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen", 15, isDisplayed()) + pressBack() + onView(withId(R.id.doctor_1_iv)).perform(click()) + waitUntilViewWithText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen", 15, isDisplayed()) + } + + private fun setDispatcher(fileName: String, responseCode: Int) { + mockServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path + if (!path.isNullOrEmpty() && path.contains("doctors-CvQD7gEAAKjcb.json")) { + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom("doctors-CvQD7gEAAKjcb.json")) + } + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom(fileName)) + } + } + } + + @After + fun tearDown() { + mockServer.shutdown() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/ErrorsTest.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/ErrorsTest.kt new file mode 100644 index 0000000..a052702 --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/ErrorsTest.kt @@ -0,0 +1,103 @@ +package com.codechallenge.doctorscatalog + +import androidx.test.espresso.Espresso +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.RootMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import com.codechallenge.doctorscatalog.di.NetworkModule +import com.codechallenge.doctorscatalog.utils.getStringFrom +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@UninstallModules(NetworkModule::class) +@HiltAndroidTest +class ErrorsTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val activityTestRule = ActivityTestRule(MainActivity::class.java, true, false) + + private lateinit var mockServer: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + mockServer = MockWebServer() + mockServer.start(8080) + } + + @Test + fun emptyJson_shouldShowErrorMessage() { + setDispatcher("empty.json", 200) + activityTestRule.launchActivity(null) + checkToast("Result can not be parsed") + } + + @Test + fun malformedJson_shouldShowErrorMessage() { + setDispatcher("malformed_doctors.json", 200) + activityTestRule.launchActivity(null) + checkToast("Result can not be parsed") + } + + @Test + fun networkError_shouldShowErrorMessage() { + setDispatcher("doctors.json", 404) + activityTestRule.launchActivity(null) + checkToast("Network error") + } + + private fun setDispatcher(fileName: String, responseCode: Int) { + mockServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path + if (!path.isNullOrEmpty() && path.contains("doctors-CvQD7gEAAKjcb.json")) { + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom("doctors-CvQD7gEAAKjcb.json")) + } + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom(fileName)) + } + } + } + + private fun checkToast(message: String) { + Espresso.onView(ViewMatchers.withText(message)) + .inRoot( + RootMatchers.withDecorView( + Matchers.not( + Matchers.`is`( + activityTestRule.activity.window.decorView + ) + ) + ) + ) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + } + + @After + fun tearDown() { + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).setOrientationNatural() + mockServer.shutdown() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/ExampleInstrumentedTest.kt deleted file mode 100644 index 6184e78..0000000 --- a/app/src/androidTest/java/com/codechallenge/doctorscatalog/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.codechallenge.doctorscatalog - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.codechallenge.doctorscatalog", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/MainTestRunner.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/MainTestRunner.kt new file mode 100644 index 0000000..731880c --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/MainTestRunner.kt @@ -0,0 +1,16 @@ +package com.codechallenge.doctorscatalog + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class MainTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/ProgressBarTest.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/ProgressBarTest.kt new file mode 100644 index 0000000..da5ebb3 --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/ProgressBarTest.kt @@ -0,0 +1,82 @@ +package com.codechallenge.doctorscatalog + +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import com.codechallenge.doctorscatalog.di.NetworkModule +import com.codechallenge.doctorscatalog.utils.* +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@UninstallModules(NetworkModule::class) +@HiltAndroidTest +class ProgressBarTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val activityTestRule = ActivityTestRule(MainActivity::class.java, true, false) + + private lateinit var mockServer: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + mockServer = MockWebServer() + mockServer.start(8080) + } + + @Test + fun progressBar_shouldAppearDuringStartAndDisappearWhenListDownloaded() { + setDispatcher("doctors.json", 200) + activityTestRule.launchActivity(null) + waitUntilProgressBarAppears(R.id.progress_pb, 1000) + waitUntilViewWithId(R.id.doctors_list_rv, 5, ViewMatchers.isDisplayed()) + waitUntilProgressBarDisappears(R.id.progress_pb, 1000) + } + + @Test + fun progressBar_shouldAppearDuringStartAndDisappearWhenError() { + setDispatcher("empty.json", 200) + activityTestRule.launchActivity(null) + waitUntilProgressBarAppears(R.id.progress_pb, 1000) + waitUntilViewWithId(R.id.doctors_list_rv, 5, ViewMatchers.isDisplayed()) + waitUntilProgressBarDisappears(R.id.progress_pb, 1000) + } + + private fun setDispatcher(fileName: String, responseCode: Int) { + mockServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path + if (!path.isNullOrEmpty() && path.contains("doctors-CvQD7gEAAKjcb.json")) { + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom("doctors-CvQD7gEAAKjcb.json")) + } + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom(fileName)) + } + } + } + + @After + fun tearDown() { + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).setOrientationNatural() + mockServer.shutdown() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/RotationTest.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/RotationTest.kt new file mode 100644 index 0000000..9e682ed --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/RotationTest.kt @@ -0,0 +1,109 @@ +package com.codechallenge.doctorscatalog + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import com.codechallenge.doctorscatalog.di.NetworkModule +import com.codechallenge.doctorscatalog.ui.main.ItemViewHolder +import com.codechallenge.doctorscatalog.utils.getStringFrom +import com.codechallenge.doctorscatalog.utils.recyclerItemAtPosition +import com.codechallenge.doctorscatalog.utils.waitUntilViewWithId +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@UninstallModules(NetworkModule::class) +@HiltAndroidTest +class RotationTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val activityTestRule = ActivityTestRule(MainActivity::class.java, true, false) + + private lateinit var mockServer: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + mockServer = MockWebServer() + mockServer.start(8080) + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).unfreezeRotation() + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).setOrientationNatural() + } + + //TODO: Investigate - probably wrong behavior! + @ExperimentalCoroutinesApi + @InternalCoroutinesApi + @Test + fun rotation_shouldMakeSecondRequest() { + setDispatcher("doctors.json", 200) + activityTestRule.launchActivity(null) + + waitUntilViewWithId(R.id.doctors_list_rv, 5, isDisplayed()) + assertEquals(1, mockServer.requestCount) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(7)).check( + matches( + recyclerItemAtPosition( + 7, + hasDescendant(withText("Jasmin Malak")) + ) + ) + ) + + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).setOrientationLeft() + waitUntilViewWithId(R.id.doctors_list_rv, 5, isDisplayed()) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(7)).check( + matches( + recyclerItemAtPosition( + 7, + hasDescendant(withText("Jasmin Malak")) + ) + ) + ) + assertEquals(2, mockServer.requestCount) + } + + private fun setDispatcher(fileName: String, responseCode: Int) { + mockServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path + if (!path.isNullOrEmpty() && path.contains("doctors-CvQD7gEAAKjcb.json")) { + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom("doctors-CvQD7gEAAKjcb.json")) + } + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom(fileName)) + } + } + } + + @After + fun tearDown() { + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).setOrientationNatural() + mockServer.shutdown() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/SearchTest.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/SearchTest.kt new file mode 100644 index 0000000..8bea18b --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/SearchTest.kt @@ -0,0 +1,124 @@ +package com.codechallenge.doctorscatalog + +import android.view.KeyEvent +import android.view.View +import android.widget.SearchView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import com.codechallenge.doctorscatalog.di.NetworkModule +import com.codechallenge.doctorscatalog.ui.main.ItemViewHolder +import com.codechallenge.doctorscatalog.utils.getStringFrom +import com.codechallenge.doctorscatalog.utils.waitUntilViewWithId +import com.codechallenge.doctorscatalog.utils.waitUntilViewWithText +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.Matcher +import org.hamcrest.Matchers.allOf +import org.junit.* +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +@UninstallModules(NetworkModule::class) +@HiltAndroidTest +class SearchTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val activityTestRule = ActivityTestRule(MainActivity::class.java, true, false) + + private lateinit var mockServer: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + mockServer = MockWebServer() + mockServer.start(8080) + } + + @ExperimentalCoroutinesApi + @InternalCoroutinesApi + @Ignore("Needs to find solution to type in SearchView") + @Test + fun clickOnVivyDoctors_shouldOpenDetails() { + setDispatcher("doctors.json", 200) + activityTestRule.launchActivity(null) + + waitUntilViewWithId(R.id.doctors_list_rv, 5, isDisplayed()) + waitUntilViewWithText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen", 15, isDisplayed()) + + onView(withId(R.id.search_doctor_bn)).perform(click()) + waitUntilViewWithId(R.id.search_doctor_sv, 5, isDisplayed()) + onView(withContentDescription("Search")).perform(click()) + typeSearchViewText("Akdenizli") + onView(withContentDescription("Search")).perform(typeText("Akdenizli")) + pressKey(KeyEvent.KEYCODE_SEARCH) + waitUntilViewWithId(R.id.search_button, 5, isDisplayed()) + onView(withId(R.id.search_button)).perform(click()) + waitUntilViewWithId(R.id.search_bar, 5, isDisplayed()) + onView(withId(R.id.search_bar)).perform(click()) + + onView(withId(R.id.search_doctor_sv)).perform(pressImeActionButton()) + + onView(withId(R.id.doctors_list_rv)).perform( + actionOnItemAtPosition( + 3, + click() + ) + ) + waitUntilViewWithId(R.id.name_tv, 5, isDisplayed()) + waitUntilViewWithText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen", 5, isDisplayed()) + } + + private fun setDispatcher(fileName: String, responseCode: Int) { + mockServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path + if (!path.isNullOrEmpty() && path.contains("doctors-CvQD7gEAAKjcb.json")) { + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom("doctors-CvQD7gEAAKjcb.json")) + } + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom(fileName)) + } + } + } + + private fun typeSearchViewText(text: String): ViewAction { + return object : ViewAction { + override fun getConstraints(): Matcher { + return allOf(isDisplayed(), isAssignableFrom(SearchView::class.java)) + } + + override fun getDescription(): String { + return "Change view text" + } + + override fun perform(uiController: UiController, view: View) { + (view as SearchView).setQuery(text, false) + } + } + } + + @After + fun tearDown() { + mockServer.shutdown() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/SortingTest.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/SortingTest.kt new file mode 100644 index 0000000..37ae813 --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/SortingTest.kt @@ -0,0 +1,136 @@ +package com.codechallenge.doctorscatalog + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import com.codechallenge.doctorscatalog.di.NetworkModule +import com.codechallenge.doctorscatalog.ui.main.HeaderViewHolder +import com.codechallenge.doctorscatalog.ui.main.ItemViewHolder +import com.codechallenge.doctorscatalog.utils.getStringFrom +import com.codechallenge.doctorscatalog.utils.recyclerItemAtPosition +import com.codechallenge.doctorscatalog.utils.waitUntilViewWithId +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@UninstallModules(NetworkModule::class) +@HiltAndroidTest +class SortingTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val activityTestRule = ActivityTestRule(MainActivity::class.java, true, false) + + private lateinit var mockServer: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + mockServer = MockWebServer() + mockServer.start(8080) + } + + /** + 10 - "Dr. med. Mario Voss" - 2.5 + 13 - "Daniel Engert and Annette Cotanidis" - 2.300000190732863 + 01 - "Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen" - 5 + 14 - "Gemeinschaftspraxis Schlesisches Tor" - 2.200000095367232 + 05 - "Jasmin Malak" - 3.299999952316282 + 15 - "Sylvia Kollmann" - 2.200000095367232 + 08 - "Dr. med. Gülenay Pamuk" - 2.599999902632568 + 11 - "Medical Office Berlin" - 2.5 + 02 - "Dr. med. Manfred Lapp specialist in general medicine" - 3.900000095367232 + 09 - "Dr. med. Christopher Marchand und Michael Hoffmann" - 2.599999902632568 + 12 - "Dorothee Michel - Specialist in General Medicine" - 2.5 + 06 - "Dr. med. Michaela Machachej Specialist f. General Medicine" - 2.900000095367232 + 20 - "Kathrin Schmidt Specialist f. General Medicine" - 2 + 16 - "Praxis am Kreuzberg, Fachärztin für Allgemeinmedizin/Hausarzt, Gabriele Fahrbach" - 2.199999809265137 + 19 - "Dr. med. Artur Jelitto" - 2.099999902632568 + 17 - "Dr. Med. Thorsten Richter" - 2.199999809265137 + 07 - "Herr Dr. med. Gerd Klausen" - 2.800000190732863 + 18 - "Praxis Lemm" - 2.199999809265137 + 04 - "Dr. med. Garney Micus" - 3.799999952316282 + 03 - "Dr. med. Casares Akdenizli" - 3.900000095367232 + */ + @ExperimentalCoroutinesApi + @InternalCoroutinesApi + @Test + fun validJson_shouldShowItemsWithDescendingSortByRating() { + setDispatcher("doctors.json", 200) + activityTestRule.launchActivity(null) + + waitUntilViewWithId(R.id.doctors_list_rv, 5, isDisplayed()) + + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(0)).check(matches( + recyclerItemAtPosition(0, hasDescendant(withText("Recent Doctors")))) + ) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(2)).check(matches( + recyclerItemAtPosition(2, hasDescendant(withText("Vivy Doctors")))) + ) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(3)).check(matches( + recyclerItemAtPosition(3, + hasDescendant(withText("Gemeinschaftspraxis Dr. Hintsche und Dr. Klausen")))) + ) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(7)).check(matches( + recyclerItemAtPosition(7, + hasDescendant(withText("Jasmin Malak")))) + ) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(12)).check(matches( + recyclerItemAtPosition(12, + hasDescendant(withText("Dr. med. Mario Voss")))) + ) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(17)).check(matches( + recyclerItemAtPosition(17, + hasDescendant(withText("Sylvia Kollmann")))) + ) + onView(withId(R.id.doctors_list_rv)) + .perform(scrollToPosition(22)).check(matches( + recyclerItemAtPosition(22, + hasDescendant(withText("Kathrin Schmidt Specialist f. General Medicine")))) + ) + } + + private fun setDispatcher(fileName: String, responseCode: Int) { + mockServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path + if (!path.isNullOrEmpty() && path.contains("doctors-CvQD7gEAAKjcb.json")) { + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom("doctors-CvQD7gEAAKjcb.json")) + } + return MockResponse() + .setResponseCode(responseCode) + .setBody(getStringFrom(fileName)) + } + } + } + + @After + fun tearDown() { + mockServer.shutdown() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/di/NetworkMockModule.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/di/NetworkMockModule.kt new file mode 100755 index 0000000..3bdd3c5 --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/di/NetworkMockModule.kt @@ -0,0 +1,45 @@ +package com.codechallenge.doctorscatalog.di + +import com.codechallenge.doctorscatalog.network.Repository +import com.codechallenge.doctorscatalog.network.RepositoryImpl +import com.codechallenge.doctorscatalog.network.RepositoryService +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ApplicationComponent +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(ApplicationComponent::class) +class NetworkMockModule { + + @Provides + @Singleton + fun getUrl(): String = "http://localhost:8080/" + + @Provides + @Singleton + fun provideGson() = GsonBuilder() + .setLenient() + .create() + + @Provides + @Singleton + fun provideRetrofit(gson: Gson, url: String) = Retrofit.Builder() + .baseUrl(url) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + @Provides + @Singleton + fun provideRepositoryService(retrofit: Retrofit) = + retrofit.create(RepositoryService::class.java) + + @Provides + @Singleton + fun provideRepository(repository: RepositoryImpl): Repository = repository +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/utils/FileInteraction.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/utils/FileInteraction.kt new file mode 100755 index 0000000..7f59508 --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/utils/FileInteraction.kt @@ -0,0 +1,19 @@ +package com.codechallenge.doctorscatalog.utils + +import androidx.test.platform.app.InstrumentationRegistry +import java.io.BufferedReader +import java.io.Reader + +/** + * Reads input file and converts to json + */ +fun getStringFrom(path: String): String { + var content = "" + val testContext = InstrumentationRegistry.getInstrumentation().context + val inputStream = testContext.assets.open(path) + val reader = BufferedReader(inputStream.reader() as Reader?) + reader.use { reader -> + content = reader.readText() + } + return content +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/codechallenge/doctorscatalog/utils/RecyclerViewMatcher.kt b/app/src/androidTest/java/com/codechallenge/doctorscatalog/utils/RecyclerViewMatcher.kt new file mode 100755 index 0000000..cd32ab3 --- /dev/null +++ b/app/src/androidTest/java/com/codechallenge/doctorscatalog/utils/RecyclerViewMatcher.kt @@ -0,0 +1,92 @@ +package com.codechallenge.doctorscatalog.utils + +import android.view.View +import androidx.annotation.NonNull +import androidx.recyclerview.widget.RecyclerView +import androidx.test.InstrumentationRegistry.getTargetContext +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject +import androidx.test.uiautomator.UiSelector +import org.awaitility.Awaitility +import org.awaitility.core.ConditionTimeoutException +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.junit.Assert +import java.util.concurrent.TimeUnit + +fun recyclerItemAtPosition(position: Int, @NonNull itemMatcher: Matcher): Matcher { + return object : BoundedMatcher(RecyclerView::class.java) { + override fun describeTo(description: Description) { + description.appendText("has item at position $position: ") + itemMatcher.describeTo(description) + } + + override fun matchesSafely(view: RecyclerView): Boolean { + val viewHolder = view.findViewHolderForAdapterPosition(position) + ?: return false + return itemMatcher.matches(viewHolder.itemView) + } + } +} + +fun waitUntilViewWithId( + viewId: Int, + timeout: Long = 3, + matcher: Matcher +) { + try { + Awaitility.await().atMost(timeout, TimeUnit.SECONDS).untilAsserted { + onView(withId(viewId)).check(matches(matcher)) + } + } catch (e: ConditionTimeoutException) { + Assert.fail("View with id: $viewId doesn't match $matcher in $timeout seconds") + } +} + +fun waitUntilViewWithText( + text: String, + timeout: Long = 3, + matcher: Matcher +) { + try { + Awaitility.await().atMost(timeout, TimeUnit.SECONDS).untilAsserted { + onView(withText(text)).check(matches(matcher)) + } + } catch (e: ConditionTimeoutException) { + Assert.fail("View with text: $text doesn't match $matcher in $timeout seconds") + } +} + +fun waitUntilProgressBarAppears( + viewId: Int, + timeoutInMs: Long = 3 +) { + try { + getUiObject(viewId).waitForExists(timeoutInMs) + } catch (e: ConditionTimeoutException) { + Assert.fail("View with id: $viewId doesn't appear in $timeoutInMs seconds") + } +} + +fun waitUntilProgressBarDisappears( + viewId: Int, + timeoutInMs: Long = 3 +) { + try { + getUiObject(viewId).waitUntilGone(timeoutInMs) + } catch (e: ConditionTimeoutException) { + Assert.fail("View with id: $viewId doesn't disappear in $timeoutInMs seconds") + } +} + +fun getUiObject(viewId: Int): UiObject { + val resourceId = getTargetContext().resources.getResourceName(viewId) + val selector = UiSelector().resourceId(resourceId) + return UiDevice.getInstance(getInstrumentation()).findObject(selector) +} \ No newline at end of file diff --git a/app/src/main/java/com/codechallenge/doctorscatalog/network/DoctorsFlowPagingSource.kt b/app/src/main/java/com/codechallenge/doctorscatalog/network/DoctorsFlowPagingSource.kt index 8749d2a..a10d138 100644 --- a/app/src/main/java/com/codechallenge/doctorscatalog/network/DoctorsFlowPagingSource.kt +++ b/app/src/main/java/com/codechallenge/doctorscatalog/network/DoctorsFlowPagingSource.kt @@ -4,6 +4,7 @@ import androidx.paging.PagingSource import com.codechallenge.doctorscatalog.data.model.api.ApiResponse import com.codechallenge.doctorscatalog.data.model.presentation.Doctor import com.codechallenge.doctorscatalog.utils.converter.ResponsesMapper +import com.google.gson.JsonSyntaxException import javax.inject.Inject class DoctorsFlowPagingSource @Inject constructor( @@ -21,14 +22,18 @@ class DoctorsFlowPagingSource @Inject constructor( transformResult(this) } } + } catch (exception: JsonSyntaxException) { + return LoadResult.Error(Throwable("Result can not be parsed")) } catch (exception: Exception) { - return LoadResult.Error(exception) + return LoadResult.Error(Throwable("Network error")) } } private fun transformResult(apiResponse: ApiResponse): LoadResult { val doctors = mapper.transform(apiResponse) - if (doctors.isEmpty()) return LoadResult.Error(Throwable("Result can not be parsed")) + if (doctors.isEmpty()) { + return LoadResult.Error(Throwable("Result can not be parsed")) + } return LoadResult.Page( data = doctors, prevKey = null,//starts from the first page diff --git a/app/src/main/java/com/codechallenge/doctorscatalog/network/RepositoryImpl.kt b/app/src/main/java/com/codechallenge/doctorscatalog/network/RepositoryImpl.kt index 80bd0f6..ee1af4f 100644 --- a/app/src/main/java/com/codechallenge/doctorscatalog/network/RepositoryImpl.kt +++ b/app/src/main/java/com/codechallenge/doctorscatalog/network/RepositoryImpl.kt @@ -5,7 +5,9 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import com.codechallenge.doctorscatalog.data.model.presentation.Doctor import com.codechallenge.doctorscatalog.utils.converter.ResponsesMapper +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -19,15 +21,17 @@ class RepositoryImpl @Inject constructor() : Repository { lateinit var mapper: ResponsesMapper override suspend fun loadFirstPage(): Flow> { - val pagingSource = DoctorsFlowPagingSource(networkService, mapper) - return Pager( - config = PagingConfig( - pageSize = 20, - enablePlaceholders = true, - prefetchDistance = 10, - initialLoadSize = 1 - ), - pagingSourceFactory = { pagingSource } - ).flow + return withContext(Dispatchers.IO) { + val pagingSource = DoctorsFlowPagingSource(networkService, mapper) + return@withContext Pager( + config = PagingConfig( + pageSize = 20, + enablePlaceholders = true, + prefetchDistance = 10, + initialLoadSize = 1 + ), + pagingSourceFactory = { pagingSource } + ).flow + } } } \ No newline at end of file diff --git a/app/src/main/java/com/codechallenge/doctorscatalog/ui/main/MainFragment.kt b/app/src/main/java/com/codechallenge/doctorscatalog/ui/main/MainFragment.kt index 8529af4..a8a4caf 100644 --- a/app/src/main/java/com/codechallenge/doctorscatalog/ui/main/MainFragment.kt +++ b/app/src/main/java/com/codechallenge/doctorscatalog/ui/main/MainFragment.kt @@ -5,6 +5,7 @@ import android.view.View import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.widget.SearchView.OnQueryTextListener +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.LifecycleOwner @@ -17,11 +18,16 @@ import androidx.recyclerview.widget.RecyclerView import com.codechallenge.doctorscatalog.R import com.codechallenge.doctorscatalog.databinding.MainFragmentBinding import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.main_fragment.* +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import javax.inject.Inject +@InternalCoroutinesApi @AndroidEntryPoint class MainFragment : Fragment(R.layout.main_fragment), LifecycleOwner { @@ -29,9 +35,9 @@ class MainFragment : Fragment(R.layout.main_fragment), LifecycleOwner { lateinit var mainAdapter: MainAdapter private lateinit var binding: MainFragmentBinding + private lateinit var linearLayoutManager: LinearLayoutManager private val viewModel by viewModels() private var lastTime = 0L - private lateinit var linearLayoutManager: LinearLayoutManager override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -61,7 +67,9 @@ class MainFragment : Fragment(R.layout.main_fragment), LifecycleOwner { } override fun onQueryTextChange(newText: String?): Boolean { - // It's bad idea to send requests on each symbol + if (!newText.isNullOrEmpty()) { + searchDoctor(newText) + } return true } }) @@ -70,7 +78,7 @@ class MainFragment : Fragment(R.layout.main_fragment), LifecycleOwner { private fun setupRecyclerView() { mainAdapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY - with(doctors_list_rv) { + with(binding.doctorsListRv) { adapter = mainAdapter layoutManager = linearLayoutManager } @@ -96,6 +104,23 @@ class MainFragment : Fragment(R.layout.main_fragment), LifecycleOwner { } } } + viewLifecycleOwner.lifecycleScope.launch { + mainAdapter.loadStateFlow.collectLatest { loadState -> + val errorMessage = (loadState.refresh as? LoadState.Error)?.error?.message + if (!errorMessage.isNullOrBlank()) { + Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show() + } + } + } + viewLifecycleOwner.lifecycleScope.launchWhenCreated { + @OptIn(FlowPreview::class) + mainAdapter.loadStateFlow + // Only emit when REFRESH LoadState for RemoteMediator changes. + .distinctUntilChangedBy { it.refresh } + // Only react to cases where Remote REFRESH completes i.e., NotLoading. + .filter { it.refresh is LoadState.NotLoading } + .collect() { binding.doctorsListRv.scrollToPosition(0) } + } } private fun startObserving() { diff --git a/app/src/main/res/layout/details_fragment.xml b/app/src/main/res/layout/details_fragment.xml index 98ecab6..6fb81e5 100644 --- a/app/src/main/res/layout/details_fragment.xml +++ b/app/src/main/res/layout/details_fragment.xml @@ -1,28 +1,33 @@ - + android:layout_height="match_parent"> - - - + android:layout_margin="@dimen/small_margin" + android:orientation="vertical"> - + + + - \ No newline at end of file + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 98a3fb8..61e8e34 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,10 @@ buildscript { ext.navigation_version = "2.3.0" ext.coil_version = "0.11.0" ext.paging_version = "3.0.0-alpha06" + ext.mockito_version = "3.4.0" + ext.mockwebserver_version = "4.9.0" + ext.espresso_version = "3.3.0" + ext.androidx_test_version = "1.3.0" repositories { google() jcenter()