第28章 测试与调试:从“能跑”到“靠谱”

说实话,很多Android开发者对测试的态度是——「能跑就行」。我以前也这么想。直到有一次,我负责的支付模块上线后,用户在特定机型上频繁闪退。那晚我盯着崩溃日志,心里只有一个念头:如果早点写单元测试,这锅根本不用背

测试不是浪费时间,而是给代码上保险。这一章,我会把Android测试体系拆开揉碎,从单元测试到性能分析,全给你讲透。

核心观点:测试不是QA的事,是工程师的尊严。你写的每一行测试,都是在为未来的自己铺路。

Android 测试体系 单元测试 JUnit 5 + Mockito 本地测试(JVM) UI 测试 Espresso / Compose Test 设备或模拟器运行 性能测试 Profiler / Battery Historian CPU/内存/耗电分析 自动化框架 CI/CD 集成(Jenkins/GitHub Actions) 持续回归测试

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、内存、网络。我一般这样用:

  1. CPU分析:打开Profiler,点击「Record」,操作App,然后停止。看哪个方法耗时最长。我上次发现一个列表卡顿,就是因为onBindViewHolder里做了图片压缩,移到后台线程后瞬间流畅。
  2. 内存分析:看堆栈信息,找泄漏。如果某个Activity被销毁后还在内存里,那就是泄漏了。用「Dump Java Heap」导出hprof文件,用MAT或LeakCanary分析。
  3. 网络分析:看每个请求的耗时和大小。我习惯把图片压缩到200KB以内,超过这个值,加载速度明显变慢。

关键指标:UI线程的帧率要稳定在60fps以上。如果掉到30fps以下,用户会明显感到卡顿。Profiler里有个「Frame Rendering」面板,专门看这个。

28.3.2 Battery Historian:揪出耗电元凶

电池续航是用户最敏感的体验之一。Google开源的Battery Historian工具,能分析手机的耗电历史。

使用方法:

  1. 在手机上执行adb bugreport bugreport.zip,导出系统报告。
  2. 把zip上传到Battery Historian网页(或本地部署的Docker实例)。
  3. 看时间轴——哪个时间段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,这些工具不是摆设。它们是你在代码世界里的安全网。嗯,这一章的内容就到这里。记住:靠谱的工程师,从不把「能跑」当标准。


公众号:蓝海资料掘金营,微信deep3321