如何编写一个自动化测试用例来验证登录流程的稳定性?

解读

国内面试中,这道题考察的不仅是“能把脚本跑起来”,而是“能否在真实业务、真实网络、真实数据、真实合规场景下,让登录用例长期稳定、可维护、可回滚”。面试官想听到的是:

  1. 你熟悉国内主流自动化框架(Espresso、UIAutomator、Robotium、Appium、Airtest、Macaca、阿里Macaca2.0、字节AirtestProject)的取舍;
  2. 你能把登录链路拆成“环境→数据→动作→断言→兜底”五层,并给出灰度、容灾、重试、埋点校验方案;
  3. 你能在不触碰隐私红线(《个人信息保护法》《工信部26条》)的前提下完成账号池管理;
  4. 你知道如何让用例在CI(企业微信/飞书/钉钉流水线)上稳定跑在真机农场(Testin、阿里MQC、华为AGC、字节火山引擎),并产出可量化的稳定性指标。

一句话:脚本人人会写,稳定才是壁垒。

知识点

  1. 登录流程原子拆解
    1.1 环境检查:网络可达、VPN/代理、MockDNS、证书校验(国密/双证书)
    1.2 账号池:合规脱敏、冷热分离、动态令牌、失效漂移
    1.3 动作编排:点击、填充、键盘弹起、WebView混合、一键登录(运营商网关、微信、QQ、微博、Apple)
    1.4 断言维度:Toast、AAR埋点、接口code、本地缓存SP/MM、DB、Push、埋点字段(uid、token、refreshToken过期时间)
    1.5 兜底策略:重试(幂等)、自动滑块/验证码打码平台(极验、网易易盾)、失败录屏、回退冷启动

  2. 框架层技术选型
    2.1 本地单元:JUnit5 + Robolectric4.9,可mock AccountManager、BiometricPrompt,适合逻辑层token刷新
    2.2 本地UI:Espresso3.5 + Barista + Kaspresso,支持IdlingResource同步接口请求,适合Native
    2.3 跨进程:UIAutomator2.2,适合系统弹窗(权限、悬浮窗、小米/华为权限适配)
    2.4 跨平台:Appium2.0 + W3C协议,真机农场并行,适合H5/小程序
    2.5 云真机:华为AGC远程shell命令修改系统设置,解决国内ROM杀后台、权限回收

  3. 数据与账号治理
    3.1 合规:姓名/手机号/身份证SHA256+盐,测试数据走“测试号码池”备案,禁止生产数据
    3.2 动态:阿里云函数计算每次返回一次性token,防止因密码失效导致误报
    3.3 隔离:用Gradle buildConfigField注入测试账号,release包强制为空,防止打包泄露

  4. 稳定性加固
    4.1 等待策略:Kaspresso的WaitForIdle、OkHttp IdlingResource、接口轮询最大30s
    4.2 重试:TestRule + RetryRule(最多3次),每次重试清应用数据并冷启动
    4.3 网络:OkHttp MockWebServer录制/回放,RTT 200ms、丢包5%、带宽限速1Mbps,模拟地铁场景
    4.4 性能:用例前后采集CPU、内存、GPU、LeakCanary检测,防止登录页WebView泄漏

  5. 度量与回滚
    5.1 指标:成功率≥99.5%,平均耗时≤2s,P95≤3s,重试率≤1%,Crash=0
    5.2 报告:Allure + 飞书群机器人,失败自动@责任人,附带录屏、logcat、接口trace
    5.3 回滚:登录接口返回4xx>5%触发蓝绿回滚,用例自动切换mock数据,保证CI不挂

答案

下面给出一套可直接落地到国内CI(以“飞书+华为AGC真机+Kaspresso”为例)的完整用例骨架,兼顾合规、稳定、可维护。
(仅展示核心代码,包名、公司名已脱敏)

  1. 环境准备
    build.gradle(app)

    android {
        defaultConfig {
            buildConfigField "String", "TEST_ACCOUNT", "\"${project.properties["test.account"] ?: ""}\""
            buildConfigField "String", "TEST_TOKEN_URL", "\"https://test-api.example.com/v1/otp\""
        }
    }
    dependencies {
        androidTestImplementation "com.kaspersky.android-components:kaspresso:1.5.1"
        androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
        androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
    }
    
  2. 测试数据池(合规)
    通过阿里云函数计算生成一次性ticket,测试用例里只保存ticket,不保存手机号。

    object AccountPool {
        suspend fun obtainTicket(): String {
            return Retrofit.Builder()
                .baseUrl(BuildConfig.TEST_TOKEN_URL)
                .build()
                .create(PoolService::class.java)
                .getTicket()
                .ticket
        }
    }
    
  3. Kaspresso用例

    @LargeTest
    class LoginStableTest : TestCase(
        kaspressoBuilder = Kaspresso.Builder.simple {
            beforeEachTest {
                // 冷启动+清数据,防止上次失败残留
                device.targetContext.getSystemService(ActivityManager::class.java)
                    .clearApplicationUserData()
            }
        }
    ) {
        @get:Rule
        val activityRule = ActivityScenarioRule(SplashActivity::class.java)
    
        @Test
        fun loginFlow_success_shouldReachHome() = run {
            step("启动页等待") {
                // 等待Splash接口返回,最长10s
                OkHttp3IdlingResource.create("splash", okHttpClient)
                onScreen<SplashScreen> {
                    logo { isDisplayed() }
                }
            }
    
            step("点击登录按钮") {
                onScreen<LoginScreen> {
                    loginBtn { click() }
                }
            }
    
            step("获取合规ticket并填充") {
                val ticket = runBlocking { AccountPool.obtainTicket() }
                onScreen<LoginScreen> {
                    phoneInput { replaceText("13800138000") } // 测试号码已备案
                    codeInput { replaceText(ticket) }
                    submitBtn { click() }
                }
            }
    
            step("处理系统弹窗") {
                // 华为/小米/OPPO权限弹窗
                device.findObject(By.text("允许")).also {
                    if (it.exists()) it.click()
                }
            }
    
            step("断言到达首页") {
                onScreen<HomeScreen> {
                    // 等待网络请求返回,最多30s
                    recyclerView { isDisplayed() }
                    // 校验本地SP已写入token
                    val sp = device.targetContext.getSharedPreferences("user", Context.MODE_PRIVATE)
                    sp.getString("access_token", null)!!.isNotEmpty()
                }
            }
    
            step("埋点校验") {
                // 读取ContentProvider暴露的埋点数据库
                val cursor = device.targetContext.contentResolver
                    .query(Uri.parse("content://com.example.tracker/log"), null, null, null, null)
                cursor.use {
                    it.moveToLast()
                    val event = it.getString(it.getColumnIndexOrThrow("event"))
                    assertEquals("login_success", event)
                }
            }
        }
    
        @Test
        fun loginFlow_wrongCode_shouldShowToast() = run {
            onScreen<LoginScreen> {
                phoneInput { replaceText("13800138000") }
                codeInput { replaceText("000000") }
                submitBtn { click() }
            }
            onScreen<LoginScreen> {
                flakySafely(timeoutMs = 5_000) {
                    ToastMatcher.onToast("验证码错误")
                }
            }
        }
    }
    
  4. RetryRule(防止偶发)

    class RetryRule(private val maxRetry: Int = 3) : TestRule {
        override fun apply(base: Statement, description: Description): Statement =
            object : Statement() {
                override fun evaluate() {
                    var error: Throwable? = null
                    for (i in 1..maxRetry) {
                        try {
                            base.evaluate()
                            return
                        } catch (t: Throwable) {
                            error = t
                            if (i < maxRetry) {
                                // 清数据+冷启动
                                InstrumentationRegistry.getInstrumentation()
                                    .uiAutomation.executeShellCommand("pm clear ${BuildConfig.APPLICATION_ID}")
                                    .close()
                            }
                        }
                    }
                    throw error!!
                }
            }
    }
    
  5. CI集成(飞书流水线)

    • 触发:每晚02:00+PR合并前
    • 真机:华为AGC 20台(Android 10-13,含折叠屏)
    • 命令:
      ./gradlew assembleDebug assembleDebugAndroidTest
      ./gradlew connectedDebugAndroidTest \
        -Pandroid.testInstrumentationRunnerArguments.class=com.example.LoginStableTest
      
    • 失败阈值:成功率<99.5%或Crash>0即阻塞发版,飞书群机器人@开发+测试owner,附带Allure报告链接与录屏。

拓展思考

  1. 小程序/Flutter混合登录如何复用同一套稳定性指标?
    思路:Appium切换context=WEBVIEW_com.tencent.mm:appbrand,通过Chrome DevTools Protocol获取wx.login回调,断言session_key有效期;Flutter Driver与原生用例结果统一上报到InfluxDB,Grafana看板合并展示。

  2. 登录用例如何与“隐私合规扫描”联动?
    在Kaspresso的afterEachTest中拉起静态扫描引擎(如腾讯PCG自研PrivacySniper),检测本次登录过程是否采集了IMEI、AndroidID、MAC地址,若出现违规字段直接标记用例失败,阻断集成。

  3. 5G弱网场景下token刷新并发冲突,用例如何模拟?
    利用Linux tc命令在真机网卡加200ms延迟+5%丢包,同时启动两个进程并发调用/refresh_token,断言仅一个请求成功返回200,另一个返回409;用例通过后可放入性能基线库,防止后续OkHttp拦截器出现竞态。

  4. 折叠屏折叠/展开时登录页重启导致WebView重载,如何保持用例稳定?
    在AndroidManifest给LoginActivity配置android:configChanges="screenSize|smallestScreenSize|orientation",并在用例里通过UiAutomator折叠设备,断言Activity未重建、WebView未重载、输入框内容未丢失;若产品层不允许配置configChanges,则用例需验证自动恢复机制(SavedStateHandle)是否把ticket写回。

  5. 长期维护成本如何量化?
    引入“用例健康度”模型:
    健康度 = 0.4×成功率 + 0.2×平均耗时反比 + 0.2×维护次数(近30天) + 0.2×代码覆盖率新增;
    当健康度<0.7自动创建Jira工单,提示重构或下线,防止“僵尸用例”堆积。