From c66460763523b35c5a21d362ddcf7157c80811da Mon Sep 17 00:00:00 2001 From: Niklas Berglund Date: Fri, 18 Oct 2024 11:08:01 +0200 Subject: [PATCH 1/2] Implement POC of page object pattern --- .../test/common/page/ConnectPage.kt | 8 +++++ .../mullvadvpn/test/common/page/LoginPage.kt | 29 ++++++++++++++++++ .../mullvadvpn/test/common/page/Page.kt | 15 ++++++++++ .../mullvad/mullvadvpn/test/e2e/LoginTest.kt | 30 ++++++++++--------- 4 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt create mode 100644 android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt create mode 100644 android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt new file mode 100644 index 000000000000..32e08f8d570c --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.test.common.page + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice + +class ConnectPage(device: UiDevice) : Page(device, pageSelector = By.res("connect_card_header_test_tag")) { + // No connect page functions needed for this POC +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt new file mode 100644 index 000000000000..57d053793789 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt @@ -0,0 +1,29 @@ +package net.mullvad.mullvadvpn.test.common.page + +import android.widget.Button +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_TIMEOUT +import net.mullvad.mullvadvpn.test.common.constant.EXTREMELY_LONG_TIMEOUT +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +class LoginPage(device: UiDevice) : Page(device, By.text("Login")) { + fun enterAccountNumber(accountNumber: String): LoginPage { + device.findObjectWithTimeout(By.clazz("android.widget.EditText")).text = accountNumber + return this + } + + fun tapLoginButton(): LoginPage { + val accountTextField = device.findObjectWithTimeout(By.clazz("android.widget.EditText")) + val loginButton = accountTextField.parent.findObject(By.clazz(Button::class.java)) + loginButton.wait(Until.enabled(true), DEFAULT_TIMEOUT) + loginButton.click() + return this + } + + fun verifyShowingInvalidAccount(): LoginPage { + device.findObjectWithTimeout(By.text("Invalid account number"), EXTREMELY_LONG_TIMEOUT) + return this + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt new file mode 100644 index 000000000000..e22bf13b580a --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.test.common.page + +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +abstract class Page(val device: UiDevice, val pageSelector: BySelector) { + init { + verifyPageShown() + } + + private fun verifyPageShown() { + device.findObjectWithTimeout(pageSelector) + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt index 5a5d70fc9f06..43262e73ecfa 100644 --- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt @@ -1,10 +1,10 @@ package net.mullvad.mullvadvpn.test.e2e -import androidx.test.uiautomator.By -import net.mullvad.mullvadvpn.test.common.constant.EXTREMELY_LONG_TIMEOUT import net.mullvad.mullvadvpn.test.common.extension.clickAgreeOnPrivacyDisclaimer import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove -import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import net.mullvad.mullvadvpn.test.common.extension.dismissChangelogDialogIfShown +import net.mullvad.mullvadvpn.test.common.page.ConnectPage +import net.mullvad.mullvadvpn.test.common.page.LoginPage import net.mullvad.mullvadvpn.test.e2e.misc.AccountTestRule import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test @@ -16,30 +16,32 @@ class LoginTest : EndToEndTest(BuildConfig.FLAVOR_infrastructure) { @Test fun testLoginWithValidCredentials() { - // Given val validTestAccountNumber = accountTestRule.validAccountNumber - // When - app.launchAndEnsureLoggedIn(validTestAccountNumber) + app.launch() + device.clickAgreeOnPrivacyDisclaimer() + device.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() + + LoginPage(device) + .enterAccountNumber(validTestAccountNumber) + .tapLoginButton() - // Then - app.ensureLoggedIn() + device.dismissChangelogDialogIfShown() + ConnectPage(device) } @Test @Disabled("Failed login attempts are highly rate limited and cause test flakiness") fun testLoginWithInvalidCredentials() { - // Given val invalidDummyAccountNumber = accountTestRule.invalidAccountNumber - // When app.launch() device.clickAgreeOnPrivacyDisclaimer() device.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() - app.waitForLoginPrompt() - app.attemptLogin(invalidDummyAccountNumber) - // Then - device.findObjectWithTimeout(By.text("Invalid account number"), EXTREMELY_LONG_TIMEOUT) + LoginPage(device) + .enterAccountNumber(invalidDummyAccountNumber) + .tapLoginButton() + .verifyShowingInvalidAccount() } } From bae916a3d48c61b488fac72d90280d1ec1de74b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Thu, 24 Oct 2024 15:32:37 +0200 Subject: [PATCH 2/2] Page object pattern, PoC 2 --- android/test/common/build.gradle.kts | 1 + .../test/common/page/ConnectPage.kt | 10 +++-- .../mullvadvpn/test/common/page/LoginPage.kt | 22 +++++------ .../mullvadvpn/test/common/page/Page.kt | 19 +++++----- .../test/common/page/PrivacyPage.kt | 38 +++++++++++++++++++ .../mullvad/mullvadvpn/test/e2e/LoginTest.kt | 38 +++++++++++-------- 6 files changed, 89 insertions(+), 39 deletions(-) create mode 100644 android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/PrivacyPage.kt diff --git a/android/test/common/build.gradle.kts b/android/test/common/build.gradle.kts index ebb719bbbd5c..fb97361369f8 100644 --- a/android/test/common/build.gradle.kts +++ b/android/test/common/build.gradle.kts @@ -57,4 +57,5 @@ dependencies { implementation(libs.kotlin.stdlib) androidTestUtil(libs.androidx.test.orchestrator) + implementation(kotlin("reflect")) } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt index 32e08f8d570c..7c3788438475 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/ConnectPage.kt @@ -1,8 +1,12 @@ package net.mullvad.mullvadvpn.test.common.page import androidx.test.uiautomator.By -import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout -class ConnectPage(device: UiDevice) : Page(device, pageSelector = By.res("connect_card_header_test_tag")) { - // No connect page functions needed for this POC +class ConnectPage internal constructor() : Page() { + override fun assertIsDisplayed() { + uiDevice.findObjectWithTimeout(By.res("connect_card_header_test_tag")) + } + + fun clickConnect() {} } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt index 57d053793789..af147e725bae 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt @@ -2,28 +2,28 @@ package net.mullvad.mullvadvpn.test.common.page import android.widget.Button import androidx.test.uiautomator.By -import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_TIMEOUT import net.mullvad.mullvadvpn.test.common.constant.EXTREMELY_LONG_TIMEOUT import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout -class LoginPage(device: UiDevice) : Page(device, By.text("Login")) { - fun enterAccountNumber(accountNumber: String): LoginPage { - device.findObjectWithTimeout(By.clazz("android.widget.EditText")).text = accountNumber - return this +class LoginPage internal constructor() : Page() { + fun enterAccountNumber(accountNumber: String) { + uiDevice.findObjectWithTimeout(By.clazz("android.widget.EditText")).text = accountNumber } - fun tapLoginButton(): LoginPage { - val accountTextField = device.findObjectWithTimeout(By.clazz("android.widget.EditText")) + fun tapLoginButton() { + val accountTextField = uiDevice.findObjectWithTimeout(By.clazz("android.widget.EditText")) val loginButton = accountTextField.parent.findObject(By.clazz(Button::class.java)) loginButton.wait(Until.enabled(true), DEFAULT_TIMEOUT) loginButton.click() - return this } - fun verifyShowingInvalidAccount(): LoginPage { - device.findObjectWithTimeout(By.text("Invalid account number"), EXTREMELY_LONG_TIMEOUT) - return this + fun verifyShowingInvalidAccount() { + uiDevice.findObjectWithTimeout(By.text("Invalid account number"), EXTREMELY_LONG_TIMEOUT) + } + + override fun assertIsDisplayed() { + uiDevice.findObjectWithTimeout(By.text("Login")) } } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt index e22bf13b580a..8835c095f866 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/Page.kt @@ -1,15 +1,16 @@ package net.mullvad.mullvadvpn.test.common.page -import androidx.test.uiautomator.BySelector +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice -import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout -abstract class Page(val device: UiDevice, val pageSelector: BySelector) { - init { - verifyPageShown() - } +sealed class Page { + protected val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - private fun verifyPageShown() { - device.findObjectWithTimeout(pageSelector) - } + abstract fun assertIsDisplayed() +} + +inline fun on(scope: T.() -> Unit = {}) { + val page = T::class.constructors.first().call() + page.assertIsDisplayed() + return page.scope() } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/PrivacyPage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/PrivacyPage.kt new file mode 100644 index 000000000000..f6a623b120c2 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/PrivacyPage.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.test.common.page + +import android.os.Build +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_TIMEOUT +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +class PrivacyPage internal constructor() : Page() { + override fun assertIsDisplayed() { + // uiDevice.findObjectWithTimeout(By.res("connect_card_header_test_tag")) + } + + fun clickAgreeOnPrivacyDisclaimer() { + uiDevice.findObjectWithTimeout(By.text("Agree and continue")).click() + } + + fun clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove( + timeout: Long = DEFAULT_TIMEOUT + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // Skipping as notification permissions are not shown. + return + } + + val selector = By.text("Allow") + + uiDevice.wait(Until.hasObject(selector), timeout) + + try { + uiDevice.findObjectWithTimeout(selector).click() + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException( + "Failed to allow notification permission within timeout ($timeout)" + ) + } + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt index 43262e73ecfa..bfd78422c0d9 100644 --- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt @@ -1,10 +1,9 @@ package net.mullvad.mullvadvpn.test.e2e -import net.mullvad.mullvadvpn.test.common.extension.clickAgreeOnPrivacyDisclaimer -import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove -import net.mullvad.mullvadvpn.test.common.extension.dismissChangelogDialogIfShown import net.mullvad.mullvadvpn.test.common.page.ConnectPage import net.mullvad.mullvadvpn.test.common.page.LoginPage +import net.mullvad.mullvadvpn.test.common.page.PrivacyPage +import net.mullvad.mullvadvpn.test.common.page.on import net.mullvad.mullvadvpn.test.e2e.misc.AccountTestRule import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test @@ -19,15 +18,18 @@ class LoginTest : EndToEndTest(BuildConfig.FLAVOR_infrastructure) { val validTestAccountNumber = accountTestRule.validAccountNumber app.launch() - device.clickAgreeOnPrivacyDisclaimer() - device.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() - LoginPage(device) - .enterAccountNumber(validTestAccountNumber) - .tapLoginButton() + on { + clickAgreeOnPrivacyDisclaimer() + clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() + } - device.dismissChangelogDialogIfShown() - ConnectPage(device) + on { + enterAccountNumber(validTestAccountNumber) + tapLoginButton() + } + + on() } @Test @@ -36,12 +38,16 @@ class LoginTest : EndToEndTest(BuildConfig.FLAVOR_infrastructure) { val invalidDummyAccountNumber = accountTestRule.invalidAccountNumber app.launch() - device.clickAgreeOnPrivacyDisclaimer() - device.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() - LoginPage(device) - .enterAccountNumber(invalidDummyAccountNumber) - .tapLoginButton() - .verifyShowingInvalidAccount() + on { + clickAgreeOnPrivacyDisclaimer() + clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() + } + + on { + enterAccountNumber(invalidDummyAccountNumber) + tapLoginButton() + verifyShowingInvalidAccount() + } } }