如何编写一个自动化测试用例来验证登录流程的稳定性?
解读
国内面试中,这道题考察的不仅是“能把脚本跑起来”,而是“能否在真实业务、真实网络、真实数据、真实合规场景下,让登录用例长期稳定、可维护、可回滚”。面试官想听到的是:
- 你熟悉国内主流自动化框架(Espresso、UIAutomator、Robotium、Appium、Airtest、Macaca、阿里Macaca2.0、字节AirtestProject)的取舍;
- 你能把登录链路拆成“环境→数据→动作→断言→兜底”五层,并给出灰度、容灾、重试、埋点校验方案;
- 你能在不触碰隐私红线(《个人信息保护法》《工信部26条》)的前提下完成账号池管理;
- 你知道如何让用例在CI(企业微信/飞书/钉钉流水线)上稳定跑在真机农场(Testin、阿里MQC、华为AGC、字节火山引擎),并产出可量化的稳定性指标。
一句话:脚本人人会写,稳定才是壁垒。
知识点
-
登录流程原子拆解
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.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.1 合规:姓名/手机号/身份证SHA256+盐,测试数据走“测试号码池”备案,禁止生产数据
3.2 动态:阿里云函数计算每次返回一次性token,防止因密码失效导致误报
3.3 隔离:用Gradle buildConfigField注入测试账号,release包强制为空,防止打包泄露 -
稳定性加固
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.1 指标:成功率≥99.5%,平均耗时≤2s,P95≤3s,重试率≤1%,Crash=0
5.2 报告:Allure + 飞书群机器人,失败自动@责任人,附带录屏、logcat、接口trace
5.3 回滚:登录接口返回4xx>5%触发蓝绿回滚,用例自动切换mock数据,保证CI不挂
答案
下面给出一套可直接落地到国内CI(以“飞书+华为AGC真机+Kaspresso”为例)的完整用例骨架,兼顾合规、稳定、可维护。
(仅展示核心代码,包名、公司名已脱敏)
-
环境准备
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" } -
测试数据池(合规)
通过阿里云函数计算生成一次性ticket,测试用例里只保存ticket,不保存手机号。object AccountPool { suspend fun obtainTicket(): String { return Retrofit.Builder() .baseUrl(BuildConfig.TEST_TOKEN_URL) .build() .create(PoolService::class.java) .getTicket() .ticket } } -
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("验证码错误") } } } } -
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!! } } } -
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报告链接与录屏。
拓展思考
-
小程序/Flutter混合登录如何复用同一套稳定性指标?
思路:Appium切换context=WEBVIEW_com.tencent.mm:appbrand,通过Chrome DevTools Protocol获取wx.login回调,断言session_key有效期;Flutter Driver与原生用例结果统一上报到InfluxDB,Grafana看板合并展示。 -
登录用例如何与“隐私合规扫描”联动?
在Kaspresso的afterEachTest中拉起静态扫描引擎(如腾讯PCG自研PrivacySniper),检测本次登录过程是否采集了IMEI、AndroidID、MAC地址,若出现违规字段直接标记用例失败,阻断集成。 -
5G弱网场景下token刷新并发冲突,用例如何模拟?
利用Linux tc命令在真机网卡加200ms延迟+5%丢包,同时启动两个进程并发调用/refresh_token,断言仅一个请求成功返回200,另一个返回409;用例通过后可放入性能基线库,防止后续OkHttp拦截器出现竞态。 -
折叠屏折叠/展开时登录页重启导致WebView重载,如何保持用例稳定?
在AndroidManifest给LoginActivity配置android:configChanges="screenSize|smallestScreenSize|orientation",并在用例里通过UiAutomator折叠设备,断言Activity未重建、WebView未重载、输入框内容未丢失;若产品层不允许配置configChanges,则用例需验证自动恢复机制(SavedStateHandle)是否把ticket写回。 -
长期维护成本如何量化?
引入“用例健康度”模型:
健康度 = 0.4×成功率 + 0.2×平均耗时反比 + 0.2×维护次数(近30天) + 0.2×代码覆盖率新增;
当健康度<0.7自动创建Jira工单,提示重构或下线,防止“僵尸用例”堆积。