Chrome 自定义选项卡意图的 Android Espresso 测试

Dav*_*vid 7 openid android ui-testing oauth-2.0 android-espresso

通过以下代码片段,我一直在尝试使用 Espresso(用于自动填写用户输入字段)使用OpenID OAuth 2.0 库UiAutomator测试登录身份验证流程,其中通过自定义 Chrome Tab 意图在外部进行登录成功登录通过启动活动的回调将用户带回应用程序,然后运行一些逻辑(通过验证在这种情况下是否显示下一个屏幕的视图来断言屏幕确实发生了变化)。但事实证明,该应用程序在登录后无法正确恢复,稍后会抛出.onActivityResult()NoActivityResumedException

是的,我尝试过使用Espresso-Intents,但无法弄清楚如何在这种情况下将其绑定,因为我将尽可能测试登录屏幕中的整体登录流程ActivityTestRule,特别是触发其自己的意图(身份验证请求按下登录按钮后。我觉得到目前为止我走在正确的轨道上,所以任何帮助我指明正确方向的帮助将不胜感激!

登录屏幕:

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)
    }

}
Run Code Online (Sandbox Code Playgroud)

辅助身份验证功能:

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)
                }
            }
    }

}
Run Code Online (Sandbox Code Playgroud)

Espresso 用户界面测试:

@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))))
    }

}
Run Code Online (Sandbox Code Playgroud)

...但LoginActivity没有恢复,如日志中所示(在 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.
Run Code Online (Sandbox Code Playgroud)