diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71242f37..e79ada78 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,3 +33,21 @@ jobs: with: lane: build options: '{ "flavor": "unbranded" }' + + instrumentation-tests: + runs-on: macos-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: test unbranded + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + arch: x86 + script: ./gradlew connectedUnbrandedWebviewDebugAndroidTest -Pabi=x86 --stacktrace + - name: run tests on gamma + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + arch: x86 + script: ./gradlew connectedMedicmobilegammaWebviewDebugAndroidTest -Pabi=x86 --stacktrace diff --git a/README.md b/README.md index 00d7b781..62605cc5 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,13 @@ This release changes the way in which location data is collected to better align 3. Plug in your phone. Check it's detected with `adb devices` 4. Execute: `make` (will also push app unto phone) +# Testing - Instrumentation Tests (UI Tests) + +1. Install Android SDK +2. Clone the repo +3. Plug in your phone. Check it's detected with `adb devices` +4. Execute: `./gradlew connected[Flavor]WebviewDebugAndroidTest` . Eg `./gradlew connectedUnbrandedWebviewDebugAndroidTest` or `./gradlew connectedMedicmobilegammaWebviewDebugAndroidTest`. At the moment we have tests only in these 2 flavors: unbranded and medicmobilegamma. To avoid failures running the tests, previous versions of the app should be uninstalled first, otherwise an `InstallException: INSTALL_FAILED_VERSION_DOWNGRADE` can make the tests to fail, and Android needs to have English as default language. + ## Connecting to the server locally Refer to the [cht-core Developer Guide](https://github.com/medic/cht-core/blob/master/DEVELOPMENT.md#testing-locally-with-devices). diff --git a/build.gradle b/build.gradle index 1ace054e..5b4a18dd 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.2' + classpath 'com.android.tools.build:gradle:4.1.3' classpath 'com.noveogroup.android:check:1.2.5' } } @@ -35,7 +35,7 @@ repositories { def simprintsApiKey, simprintsModuleId, simprintsUserId def getVersionCode = { - int versionCode = 1 + int versionCode = 2 if(System.env.CI == 'true' && System.env.RELEASE_VERSION && System.env.RELEASE_VERSION.startsWith('v')) { def versionParts = System.env.RELEASE_VERSION.split(/[^0-9]+/) @@ -72,6 +72,16 @@ android { archivesBaseName = "${project.name}-${versionName}" targetSdkVersion 29 // When upgrading targetSdkVersion, check that the app menu still works on newer devices. + //for espresso tests + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + //test user credentials + buildConfigField "String", "TEST_USERNAME", "\"${System.env.ANDROID_TEST_USERNAME}\"" + buildConfigField "String", "TEST_PASSWORD", "\"${System.env.ANDROID_TEST_PASSWORD}\"" + if(System.env.ANDROID_TEST_URL){ + buildConfigField "String", "SERVER_URL", "\"${System.env.ANDROID_TEST_URL}\"" + }else { + buildConfigField "String", "SERVER_URL", '"https://gamma-cht.dev.medicmobile.org"' + } } applicationVariants.all { variant -> @@ -102,7 +112,7 @@ android { buildConfigField "String", "SIMPRINTS_MODULE_ID", '"Medic Module ID"' } - // Every APK requires a unique version code. + // Every APK requires a unique version code. // So when compiling multiple APKS for the different ABIs, use the first digit variant.outputs.each { output -> def versionAugmentation = (output.getFilter(OutputFile.ABI) == 'arm64-v8a') ? 1 : 0; @@ -315,12 +325,12 @@ android { splits { abi { - enable true + enable !project.hasProperty('abi') reset() include( - 'armeabi-v7a', - 'arm64-v8a', - //'x86', //--> uncomment to be able to deploy the app in Android 10+ virtual devices + 'armeabi-v7a', + 'arm64-v8a', + //'x86', //--> uncomment to be able to deploy the app in Android 10+ virtual devices ) universalApk false } @@ -341,4 +351,10 @@ dependencies { testImplementation 'junit:junit:4.12' testImplementation 'com.google.android:android-test:4.1.1.4' testImplementation 'org.robolectric:robolectric:4.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0' + androidTestImplementation 'androidx.test:runner:1.1.0' + androidTestImplementation 'androidx.test:rules:1.1.0' + androidTestImplementation 'androidx.test.espresso:espresso-web:3.1.0' + androidTestImplementation 'androidx.test:core:1.3.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' } diff --git a/src/androidTestMedicmobilegammaWebviewDebug/java/org/medicmobile/webapp/mobile/LoginTests.java b/src/androidTestMedicmobilegammaWebviewDebug/java/org/medicmobile/webapp/mobile/LoginTests.java new file mode 100644 index 00000000..6fdb4a81 --- /dev/null +++ b/src/androidTestMedicmobilegammaWebviewDebug/java/org/medicmobile/webapp/mobile/LoginTests.java @@ -0,0 +1,115 @@ +package org.medicmobile.webapp.mobile; + +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import androidx.test.espresso.DataInteraction; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.web.webdriver.DriverAtoms; +import androidx.test.espresso.web.webdriver.Locator; +import androidx.test.filters.LargeTest; +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; +import androidx.test.rule.ActivityTestRule; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import java.util.Locale; +import static androidx.test.espresso.Espresso.onData; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withParent; +import static androidx.test.espresso.web.assertion.WebViewAssertions.webContent; +import static androidx.test.espresso.web.assertion.WebViewAssertions.webMatches; +import static androidx.test.espresso.web.matcher.DomMatchers.hasElementWithId; +import static androidx.test.espresso.web.sugar.Web.onWebView; +import static androidx.test.espresso.web.webdriver.DriverAtoms.clearElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.getText; +import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.containsString; + +@LargeTest +@RunWith(AndroidJUnit4ClassRunner.class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class LoginTests { + + private static final String ERROR_INCORRECT = "Incorrect user name or password. Please try again."; + + @Rule + public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(SettingsDialogActivity.class); + + @Test + public void testLoginScreen() throws Exception { + DataInteraction linearLayout = onData(anything()) + .inAdapterView(allOf(withId(R.id.lstServers), + childAtPosition( + withId(android.R.id.content), + 0))) + .atPosition(2); + linearLayout.perform(click()); + Thread.sleep(10000);//TODO: use better ways to handle delays + + ViewInteraction webView = onView( + allOf(withId(R.id.wbvMain), + withParent(allOf(withId(R.id.lytWebView), + withParent(withId(android.R.id.content)))), + isDisplayed())); + webView.check(matches(isDisplayed())); + onWebView() + .check(webContent(hasElementWithId("form"))) + .withElement(findElement(Locator.ID, "locale")) + .check(webMatches(getText(), containsString("English"))); + String[] codes = {"es", "en", "fr", "sw"}; + for (String code : codes) { + onWebView().withElement(findElement(Locator.NAME, code)) + .check(webMatches(getText(), containsString(getLanguage(code)))); + } + + //login form and errors + onWebView().withElement(findElement(Locator.ID, "user")) + .perform(clearElement()) + .perform(DriverAtoms.webKeys("fakeName")) //to be created first + .withElement(findElement(Locator.ID, "password")) + .perform(clearElement()) + .perform(DriverAtoms.webKeys("fake_password")) + .withElement(findElement(Locator.ID, "login")) + .perform(webClick()); + Thread.sleep(4000);//TODO: use better ways to handle delays - takes longer with emulators + onWebView().withElement(findElement(Locator.CSS_SELECTOR, "p.error.incorrect")) + .check(webMatches(getText(), containsString(ERROR_INCORRECT))); + } + + private String getLanguage(String code) { + Locale aLocale = new Locale(code); + return aLocale.getDisplayName(); + } + + private static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +} diff --git a/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java new file mode 100644 index 00000000..d80fe7a1 --- /dev/null +++ b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java @@ -0,0 +1,150 @@ +package org.medicmobile.webapp.mobile; + + +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import androidx.test.espresso.DataInteraction; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.web.webdriver.DriverAtoms; +import androidx.test.espresso.web.webdriver.Locator; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; + +import java.util.Locale; + +import static androidx.test.espresso.Espresso.onData; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasErrorText; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withParent; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.espresso.web.assertion.WebViewAssertions.webContent; +import static androidx.test.espresso.web.assertion.WebViewAssertions.webMatches; +import static androidx.test.espresso.web.matcher.DomMatchers.hasElementWithId; +import static androidx.test.espresso.web.sugar.Web.onWebView; +import static androidx.test.espresso.web.webdriver.DriverAtoms.clearElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.getText; +import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.containsString; +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; + + +@LargeTest +@RunWith(AndroidJUnit4ClassRunner.class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class SettingsDialogActivityTest { + + private static final String WEBAPP_URL = "Webapp URL"; + private static final String SERVER_ONE = "https://medic.github.io/atp"; + private static final String SERVER_TWO = "https://gamma-cht.dev.medicmobile.org"; + private static final String SERVER_THREE = "https://gamma.dev.medicmobile.org"; + private static final String ERROR_INCORRECT = "Incorrect user name or password. Please try again."; + + @Rule + public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(SettingsDialogActivity.class); + + @Test + public void serverSelectionScreenIsDisplayed() { + onView(withText("Medic Mobile")).check(matches(isDisplayed())); + onView(withText("Custom")).check(matches(isDisplayed())); + onView(withId(R.id.lstServers)).check(matches(isDisplayed())); + + onView(withText(SERVER_ONE)).check(matches(isDisplayed())); + onView(withText(SERVER_TWO)).check(matches(isDisplayed())); + onView(withText(SERVER_THREE)).check(matches(isDisplayed())); + + onView(withText("Custom")).perform(click()); + ViewInteraction textAppUrl = onView(withId(R.id.txtAppUrl)); + textAppUrl.check(matches(withHint(WEBAPP_URL))); + + textAppUrl.perform(replaceText("something"), closeSoftKeyboard()); + onView(withId(R.id.btnSaveSettings)).perform(click()); + textAppUrl.check(matches(hasErrorText("must be a valid URL"))); + pressBack(); + + } + + @Test + public void testLoginScreen() throws Exception { + DataInteraction linearLayout = onData(anything()) + .inAdapterView(allOf(withId(R.id.lstServers), + childAtPosition( + withId(android.R.id.content), + 0))) + .atPosition(2); + linearLayout.perform(click()); + Thread.sleep(10000);//TODO: use better ways to handle delays + + ViewInteraction webView = onView( + allOf(withId(R.id.wbvMain), + withParent(allOf(withId(R.id.lytWebView), + withParent(withId(android.R.id.content)))), + isDisplayed())); + webView.check(matches(isDisplayed())); + onWebView() + .check(webContent(hasElementWithId("form"))) + .withElement(findElement(Locator.ID, "locale")) + .check(webMatches(getText(), containsString("English"))); + String[] codes = {"es", "en", "fr", "sw"}; + for (String code : codes) { + onWebView().withElement(findElement(Locator.NAME, code)) + .check(webMatches(getText(), containsString(getLanguage(code)))); + } + + //login form and errors + onWebView().withElement(findElement(Locator.ID, "user")) + .perform(clearElement()) + .perform(DriverAtoms.webKeys("fakename")) //to be created first + .withElement(findElement(Locator.ID, "password")) + .perform(clearElement()) + .perform(DriverAtoms.webKeys("fake_password")) + .withElement(findElement(Locator.ID, "login")) + .perform(webClick()); + Thread.sleep(4000);//TODO: use better ways to handle delays - takes longer with emulators + onWebView().withElement(findElement(Locator.CSS_SELECTOR, "p.error.incorrect")) + .check(webMatches(getText(), containsString(ERROR_INCORRECT))); + } + + private String getLanguage(String code) { + Locale aLocale = new Locale(code); + return aLocale.getDisplayName(); + } + + private static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +}