Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI Testing with Espresso for Authorization Request and Response of Intents #492

Open
davenotdavid opened this issue Sep 3, 2019 · 5 comments

Comments

@davenotdavid
Copy link

Current environment including, but not limited to:

  • AppAuth dependency version: 0.7.1
  • Android Studio Gradle version: 3.5.0
  • JUnit version: 4.12
  • Espresso Test version: 3.2.0
  • Hamcrest version: 1.3
  • UI Automator version: 2.2.0

With the following code snippets, I've been trying to use Espresso (UiAutomator for automatically filling in the user input fields) for testing out the login auth flow where logging in occurs externally via a custom Chrome Tab intent with a successful login taking the user back to the app via the launching Activity's onActivityResult() callback to then run some logic afterwards (asserting that the screen really changed by validating that the next screen's views are being displayed in this case). But it turns out that the app isn't resumed properly after logging in which later throws a NoActivityResumedException.

And yes, I've tried using Espresso Intents, but couldn't figure out how to tie it in this scenario since I'm going as far as testing the overall login flow within the login screen as the ActivityTestRule, particularly triggering its own intent (auth request) after the login button is pressed. I feel like I'm on the right track so far, so any help would be appreciated on pointing me to the right direction!

Login screen:

class LoginActivity : AppCompatActivity() {

    companion object {
        const val RC_AUTH_LOGIN = 100
    }

    private lateinit var authService: AuthorizationService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)

        initAuthService()
        initViews()
    }

    override fun onDestroy() {
        authService.dispose()
        super.onDestroy()
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        if (resultCode == Activity.RESULT_OK) {
            when (requestCode) {
                RC_AUTH_LOGIN -> initViewModelAndObserve(data)
                else -> // Display error message
            }
        }
    }

    private fun initAuthService() {
        authService = AuthorizationService(this)
    }

    private fun initViews() {
        start_auth_button?.setOnClickListener {
            startAuthorization()
        }
    }

    private fun initViewModelAndObserve(data: Intent?) {
        // [authState] can either be retrieved from cache or [AuthState()]
        AuthUtils.handleAuthorizationResponse(authService, data, authState) { success ->
            if (success) {
                // Run necessary API async calls and such within the ViewModel 
                // layer to observe.
                loginViewModel.loginLiveData.observe(this, Observer<Boolean> { loginSuccessful ->
                    if (loginSuccessful) {
                        // Transition to the next screen
                    } else {
                        // Display error message
                    }
                })
            } else {
                // Display error message
            }
        }
    }

    private fun startAuthorization() {
        val req = AuthUtils.getAuthRequest()
        val intent = authService.getAuthorizationRequestIntent(req)
        startActivityForResult(intent, RC_AUTH_LOGIN)
    }

}

Helper auth functions:

object AuthUtils {

    fun getAuthRequest(): AuthorizationRequest {
        val authServiceConfig = getServiceConfig()
        // [clientID], [redirectURI], and [clientSecret] dummy 
        // args.
        val req = AuthorizationRequest.Builder(
            authServiceConfig,
            clientID,
            ResponseTypeValues.CODE,
            Uri.parse(redirectURI)
        )
            .setScope("scope")
            .setPrompt("login")
            .setAdditionalParameters(mapOf("client_secret" to clientSecret,"grant_type" to "authorization_code" ))
            .build()

        return req
    }

    fun handleAuthorizationResponse(authService: AuthorizationService,
                                    data: Intent?,
                                    appAuthState: AuthState,
                                    resultCallBack: (result: Boolean) -> Unit) {

        if (data == null) {
            resultCallBack.invoke(false)
            return
        }

        val response = AuthorizationResponse.fromIntent(data)
        val error = AuthorizationException.fromIntent(data)
        appAuthState.update(response, error)
        if (error != null || response == null) {
            resultCallBack.invoke(false)
            return
        }

        val req = getTokenRequest(response)
        performTokenRequest(authService, req, appAuthState) { authState ->
            if (authState != null) {
                authState.accessToken?.let { token ->
                    // For instance, decode token here prior to caching.
                    resultCallBack.invoke(true)
                }
            } else {
                resultCallBack.invoke(false)
            }
        }
    }

    private fun getServiceConfig(): AuthorizationServiceConfiguration {
        // Issuer URI (login URL in this case) dummy arg
        return authServiceConfig = AuthorizationServiceConfiguration(
            Uri.parse(issuerURI)
                .buildUpon()
                .appendEncodedPath("connect/authorize")
                .build(),
            Uri.parse(issuerURI)
                .buildUpon()
                .appendEncodedPath("connect/token")
                .build()
        )
    }

    private fun getTokenRequest(response: AuthorizationResponse) : TokenRequest {
        val request = getAuthRequest()
        val secret = RemoteConfig().clientSecret()

        return TokenRequest.Builder(
            request.configuration,
            request.clientId)
            .setGrantType(GrantTypeValues.AUTHORIZATION_CODE)
            .setRedirectUri(request.redirectUri)
            .setScope(request.scope)
            // this is not valid in ID server
            // .setCodeVerifier(request.codeVerifier)
            .setAuthorizationCode(response.authorizationCode)
            .setAdditionalParameters(mapOf("client_secret" to secret))
            .build()
    }

    private fun performTokenRequest(authService: AuthorizationService,
                                    req: TokenRequest,
                                    appAuthState: AuthState,
                                    resultCallBack:(result: AuthState?) -> Unit)  {

        authService
            .performTokenRequest(req) { response, error ->
                // Updates auth state based on if there's token response
                // data or not.
                if (response != null) {
                    appAuthState.update(response, error)
                    resultCallBack.invoke(appAuthState)
                } else {
                    resultCallBack.invoke(null)
                }
            }
    }

}

Espresso UI test:

@LargeTest
@RunWith(AndroidJUnit4::class)
class LoginAuthInstrumentedTest { 

    private val context = InstrumentationRegistry.getInstrumentation().targetContext

    @Rule
    @JvmField
    var activityTestRule = ActivityTestRule(LoginActivity::class.java)

    @Test
    fun loginAuthFlow_isCorrect() {
        // Performs a click action in the login screen to fire off
        // the auth service intent for an activity result.
        onView(withId(R.id.start_auth_button)).perform(click())

        // Automatically logs the user in with dummy creds within a
        // custom Chrome tab intent (via the OpenID auth library).
        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        val selector = UiSelector()
        val usernameInputObject = device.findObject(selector.resourceId("username"))
        usernameInputObject.click()
        usernameInputObject.text = "testuser@testapp.com"
        val passwordInputObject = device.findObject(selector.resourceId("password"))
        passwordInputObject.click()
        passwordInputObject.text = "testpassword"
        val loginBtnObject = device.findObject(selector.resourceId("cmdLogin"))
        loginBtnObject.click()

        // Upon a successful login from the auth service, the following
        // asserts that the following views are shown on the next
        // transitioned screen.
        onView(withId(R.id.main_screen_header)).check(matches(withText(context.getString(R.string.main_screen_header_text))))
        onView(withId(R.id.main_screen_subheader)).check(matches(withText(context.getString(R.string.main_screen_subheader_text))))
        onView(withId(R.id.main_screen_description)).check(matches(withText(context.getString(R.string.main_screen_description_text))))
    }

}

... but LoginActivity is not resumed as shown here in the logs (prior to a NoActivityResumedException):

D/LifecycleMonitor: Lifecycle status change: com.testapp.view.login.LoginActivity@de1a309 in: STOPPED
    running callback: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
    callback completes: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
D/LifecycleMonitor: Lifecycle status change: net.openid.appauth.AuthorizationManagementActivity@76192e1 in: STOPPED
    running callback: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
    callback completes: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=username] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3d2f; boundsInParent: Rect(0, 28 - 382, 81); boundsInScreen: Rect(39, 782 - 1042, 921); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: username; checkable: false; checked: false; focusable: true; focused: false; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: false; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_FOCUS - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
D/InteractionController: clickAndSync(540, 851)
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=username] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3d2f; boundsInParent: Rect(0, 28 - 382, 81); boundsInScreen: Rect(39, 782 - 1042, 921); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: username; checkable: false; checked: false; focusable: true; focused: true; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: false; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_CLEAR_FOCUS - null, AccessibilityAction: ACTION_CLEAR_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=password] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3f1f; boundsInParent: Rect(0, 0 - 317, 8); boundsInScreen: Rect(39, 1034 - 871, 1055); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: password; checkable: false; checked: false; focusable: true; focused: false; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: true; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_FOCUS - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
D/InteractionController: clickAndSync(455, 1044)
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=password] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3f1f; boundsInParent: Rect(0, 0 - 317, 8); boundsInScreen: Rect(39, 1034 - 871, 1055); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: password; checkable: false; checked: false; focusable: true; focused: false; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: true; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_FOCUS - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=cmdSubmit] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3dab; boundsInParent: Rect(0, 131 - 382, 132); boundsInScreen: Rect(39, 1052 - 1042, 1055); packageName: com.android.chrome; className: android.widget.Button; text: Sign In; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: cmdSubmit; checkable: false; checked: false; focusable: false; focused: false; selected: false; clickable: false; longClickable: false; contextClickable: false; enabled: false; password: false; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null]]
D/InteractionController: clickAndSync(540, 1053)
V/FA: Inactivity, disconnecting from the service
W/RootViewPicker: No activity currently resumed - waiting: 10ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 50ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 100ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 500ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 2000ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 30000ms for one to appear.
@tobi512
Copy link

tobi512 commented Nov 19, 2019

Hi @davenotdavid,
still running into problems here?
I have a working setup to test the authorization flow using UiAutomator (no Espresso), so it probably seems to be an issue with your test config...

@ishaan-khan
Copy link

@tobi512 could you please share the working test setup?

@tobi512
Copy link

tobi512 commented Jun 9, 2022

Hey @ishaan-khan
not sure if that code still exists (and more important: still works). It was 3 years ago, I'll check in the coming days and let you know when I still find it...

@ishaan-khan
Copy link

Hi @tobi512
thanks for responding. Were you able to get hold of the code? I'm quite hopeful that it will help me with this issue.

@tobi512
Copy link

tobi512 commented Jun 24, 2022

Hey @ishaan-khan
sorry was pretty busy these days, but I found the code and I'll leave it here for you. It's not used in production anymore, so can't guarantee that it still works and can't give further support here, good luck. 😄
(I remember that I somehow took it from a tutorial I found somewhere on the web with slight modifications).

LoginUiTest.kt


import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LoginUiTest {

    private lateinit var device: UiDevice

    /**
     * Uses package manager to find the package name of the device launcher. Usually this package
     * is "com.android.launcher" but can be different at times. This is a generic solution which
     * works on all platforms.`
     */
    private val launcherPackageName: String
        get() {
            // Create launcher Intent
            val intent = Intent(Intent.ACTION_MAIN)
            intent.addCategory(Intent.CATEGORY_HOME)
            // Use PackageManager to get the launcher package name
            val pm = getApplicationContext<Context>().packageManager
            val resolveInfo = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
            return resolveInfo!!.activityInfo.packageName
        }

    @Before
    fun startMainActivityFromHomeScreen() {
        // Initialize UiDevice instance
        device = UiDevice.getInstance(getInstrumentation())

        // Start from the home screen
        device.pressHome()

        // Wait for launcher
        val launcherPackage = launcherPackageName
        assertNotNull(launcherPackage)
        device.wait(Until.hasObject(By.pkg(launcherPackage).depth(0)), TIMEOUT_MILLIS.toLong())

        // Launch the app
        val context = getApplicationContext<Context>()
        val intent = context.packageManager
            .getLaunchIntentForPackage(EXAMPLE_APP_PACKAGE)
        intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) // Clear out any previous instances
        context.startActivity(intent)

        // Wait for the app to appear
        device.wait(
            Until.hasObject(By.pkg(EXAMPLE_APP_PACKAGE).depth(0)),
            TIMEOUT_MILLIS,
        )
    }

    @Test
    fun testSuccessfulLogin() {
        assertNotNull(device) // If this assert fails, something with the setup is wrong

        // navigate to the login button on settings tab
        val settingsTab = By.res(EXAMPLE_APP_PACKAGE, "settings")
        device.wait(Until.findObject(settingsTab), TIMEOUT_MILLIS * 2)
        device.findObject(settingsTab).click()

        device.findObject(By.res(EXAMPLE_APP_PACKAGE, "login_button")).click()

        // The first run experience of chrome will show up at this point. Click through the pages since overriding chrome settings doesn't work.
        device.findObject(UiSelector().resourceId("com.android.chrome:id/terms_accept")).click()
        device.findObject(UiSelector().resourceId("com.android.chrome:id/negative_button")).click()

        // All elements on the login webview are best accessible via UiSelector
        //  (use the "uiautomatorviewer" from <Android-SDK>/tools/bin to check their resourceIds)
        device.wait(Until.findObject(By.text("Login")), TIMEOUT_MILLIS * 2)

        device.findObject(UiSelector().resourceId("emailAddress-input")).text = EMAIL
        device.findObject(UiSelector().resourceId("password-input")).text = PASSWORD

        // this is used as a waiting time, the resource doesn't exist on purpose!
        device.wait(Until.findObject(By.text("Unknown")), TIMEOUT_MILLIS)

        device.findObject(UiSelector().text("Login")).click()

        device.wait(Until.findObject(By.res(EXAMPLE_APP_PACKAGE, "customer_id")), TIMEOUT_MILLIS * 2)

        val customerId = device.findObject(By.res(EXAMPLE_APP_PACKAGE, "customer_account_id"))
        assertEquals(customerId.text, SOME_CUSTOMER_ID)
    }

    companion object {
        private val EXAMPLE_APP_PACKAGE = "com.example.app"
        private val TIMEOUT_MILLIS = 5000L

        private val EMAIL = "test@example.com"
        private val PASSWORD = "passw0rd"
        private val SOME_CUSTOMER_ID = "Customer id: 123456"
    }
}

Following dependencies were added for it in the build.gradle:

androidTestImplementation 'androidx.test.ext:junit:1.1.2-alpha02'
androidTestImplementation 'androidx.test:runner:1.3.0-alpha02'
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
androidTestImplementation 'org.hamcrest:hamcrest-integration:1.3'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants