第28章 测试与调试:从“能跑”到“靠谱”
说实话,很多Android开发者对测试的态度是——「能跑就行」。我以前也这么想。直到有一次,我负责的支付模块上线后,用户在特定机型上频繁闪退。那晚我盯着崩溃日志,心里只有一个念头:如果早点写单元测试,这锅根本不用背。
测试不是浪费时间,而是给代码上保险。这一章,我会把Android测试体系拆开揉碎,从单元测试到性能分析,全给你讲透。
核心观点:测试不是QA的事,是工程师的尊严。你写的每一行测试,都是在为未来的自己铺路。
28.1 单元测试:给代码上第一道保险
单元测试测什么?说白了,就是测你写的每个方法、每个逻辑分支,在给定输入下,输出对不对。它跑在JVM上,不需要真机,速度飞快。
28.1.1 JUnit 5 基础
JUnit是Java生态的测试框架,Android里最常用的是JUnit 5。我习惯在build.gradle里这样配:
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
testImplementation 'org.mockito:mockito-core:5.3.1'
testImplementation 'org.mockito:mockito-inline:5.3.1'
}
一个简单的测试用例长这样:
class CalculatorTest {
private val calculator = Calculator()
@Test
fun `加法应该返回正确结果`() {
val result = calculator.add(2, 3)
assertEquals(5, result)
}
@Test
fun `除以零应该抛出异常`() {
assertThrows<IllegalArgumentException> {
calculator.divide(10, 0)
}
}
}
我的习惯:测试方法名用中文描述,团队里一看就懂。别写testAdd1这种名字,三个月后你自己都看不懂。
28.1.2 Mockito:搞定依赖
真实项目里,一个方法往往依赖网络、数据库或别的模块。你不可能在单元测试里真的去联网。这时候就需要Mockito——它帮你「伪造」依赖对象。
举个例子,我有一个UserRepository,它依赖ApiService:
class UserRepository(private val api: ApiService) {
fun getUser(id: String): User {
return api.fetchUser(id)
}
}
测试时,我不想真的调用网络。用Mockito伪造ApiService:
class UserRepositoryTest {
@Mock
private lateinit var api: ApiService
private lateinit var repository: UserRepository
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
repository = UserRepository(api)
}
@Test
fun `getUser应该返回mock数据`() {
val mockUser = User("1", "张三")
`when`(api.fetchUser("1")).thenReturn(mockUser)
val result = repository.getUser("1")
assertEquals("张三", result.name)
verify(api).fetchUser("1")
}
}
注意最后那行verify——它检查api.fetchUser是否真的被调用了一次。这是Mockito的杀手锏,帮你验证交互行为。
我曾经踩过的坑:Mockito默认不能mock final类和静态方法。如果你用Kotlin,类默认是final的。解决方案是用mockito-inline扩展库,或者在build.gradle里加testOptions { unitTests.returnDefaultValues = true }。
28.2 UI测试:让界面自己跑一遍
单元测试测逻辑,UI测试测界面。你想想看,一个按钮点下去,页面跳没跳?列表加载完,数据对不对?这些靠人肉点,点一次两次还行,每次发版前点一遍,谁受得了?
28.2.1 Espresso:传统View体系的利器
Espresso是Google官方的UI测试框架,专为传统XML布局设计。它的核心思想是:找到控件,执行操作,验证结果。
@Test
fun 点击登录按钮应该跳转到主页() {
// 找到登录按钮
onView(withId(R.id.btn_login))
.perform(click())
// 验证是否跳转到主页
onView(withId(R.id.tv_welcome))
.check(matches(isDisplayed()))
}
Espresso最牛的地方是它的同步机制——它会自动等待主线程空闲再执行下一步。你不用写Thread.sleep,它自己会等。
我的建议:写UI测试时,尽量用withId定位控件,别用withText。文本可能因国际化变化,id一般不会变。
28.2.2 Compose Test:新世界的玩法
如果你用Jetpack Compose,UI测试的写法完全不同。Compose Test基于语义树,不依赖控件ID,而是靠内容描述来定位。
class LoginScreenTest {
@Rule
@JvmField
val composeTestRule = createComposeRule()
@Test
fun 输入用户名密码后点击登录() {
composeTestRule.setContent {
LoginScreen()
}
composeTestRule
.onNodeWithText("用户名")
.performTextInput("admin")
composeTestRule
.onNodeWithText("密码")
.performTextInput("123456")
composeTestRule
.onNodeWithText("登录")
.performClick()
composeTestRule
.onNodeWithText("欢迎回来")
.assertIsDisplayed()
}
}
Compose Test的语法更简洁,但有个坑——它默认只匹配可见节点。如果某个组件被折叠或隐藏,你直接assert会失败。记得用assertExists()代替assertIsDisplayed()来绕过。
28.3 性能测试:别让用户骂你卡
功能跑通了,不代表能上线。我见过太多App,功能没问题,但一上真机就卡成PPT。性能测试,就是提前发现这些「隐形炸弹」。
28.3.1 Android Profiler:实时监控三件套
Android Studio自带的Profiler,能实时看CPU、内存、网络。我一般这样用:
- CPU分析:打开Profiler,点击「Record」,操作App,然后停止。看哪个方法耗时最长。我上次发现一个列表卡顿,就是因为onBindViewHolder里做了图片压缩,移到后台线程后瞬间流畅。
- 内存分析:看堆栈信息,找泄漏。如果某个Activity被销毁后还在内存里,那就是泄漏了。用「Dump Java Heap」导出hprof文件,用MAT或LeakCanary分析。
- 网络分析:看每个请求的耗时和大小。我习惯把图片压缩到200KB以内,超过这个值,加载速度明显变慢。
关键指标:UI线程的帧率要稳定在60fps以上。如果掉到30fps以下,用户会明显感到卡顿。Profiler里有个「Frame Rendering」面板,专门看这个。
28.3.2 Battery Historian:揪出耗电元凶
电池续航是用户最敏感的体验之一。Google开源的Battery Historian工具,能分析手机的耗电历史。
使用方法:
- 在手机上执行
adb bugreport bugreport.zip,导出系统报告。 - 把zip上传到Battery Historian网页(或本地部署的Docker实例)。
- 看时间轴——哪个时间段CPU唤醒频繁?哪个App在后台疯狂定位?
我曾经发现一个第三方SDK,每5秒唤醒一次CPU做统计。用户手机半天就没电了。后来我们换成了批量上报,耗电直接降了70%。
注意:Battery Historian分析的是系统级数据,需要root或工程版系统才能拿到完整信息。普通调试机也能用,但数据会少一些。
28.4 自动化测试框架:让机器替你打工
手动测试终究不是长久之计。我见过一个团队,每次发版前要花两天时间回归测试。后来他们接入了自动化框架,回归时间缩短到2小时。
常见的自动化方案:
| 框架 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Jenkins + Gradle | 传统CI/CD | 生态成熟,插件多 | 配置复杂 |
| GitHub Actions | 开源项目 | 免费,与GitHub深度集成 | 调试不方便 |
| Firebase Test Lab | 云端真机测试 | 覆盖上百种机型 | 收费 |
我个人的推荐是:小团队用GitHub Actions,大团队用Jenkins。GitHub Actions的配置文件很简单:
name: Android CI
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Run UI tests
run: ./gradlew connectedDebugAndroidTest
每次push代码,自动跑所有测试。如果测试失败,PR直接标红。这招倒逼团队写出更健壮的代码——谁也不想自己的PR因为测试不过而被卡住。
我的经验:自动化测试不要追求100%覆盖率。核心业务逻辑覆盖到80%以上就够了。有些UI细节,比如动画效果,手动看一眼比写自动化测试更高效。
写在最后
测试这件事,说白了就是「用今天的投入,换明天的安稳」。你可能会觉得写测试很烦,但当你半夜被线上bug叫醒时,你会感谢那个曾经认真写测试的自己。
从JUnit到Mockito,从Espresso到Profiler,这些工具不是摆设。它们是你在代码世界里的安全网。嗯,这一章的内容就到这里。记住:靠谱的工程师,从不把「能跑」当标准。