一、测试思维与TDD哲学:为什么需要单元测试?

说实话,我刚开始做Android开发那几年,对单元测试是有点抵触的。总觉得「写代码都来不及,还写测试?」。直到有一次,我负责的一个支付模块上线后出了严重bug,导致用户重复扣款。那次事故让我意识到——没有测试的代码,就像在悬崖边跳舞。

今天这一章,我想跟你聊聊测试思维和TDD哲学。这不是什么高大上的理论,而是我踩过无数坑之后总结出来的实战经验。

1.1 为什么需要单元测试?

先问个问题:你写代码的时候,有没有遇到过这种情况?

  • 改了一行代码,结果另一个模块崩了
  • 上线前信心满满,上线后bug不断
  • 重构代码时,根本不敢动,怕改出问题

嗯,这些我都经历过。单元测试就是来解决这些问题的。

单元测试的核心价值,说白了就三点:

  1. 快速反馈:写完代码立刻知道对不对,不用等编译、安装、跑流程
  2. 安全重构:有测试兜底,改代码时心里有底
  3. 文档即代码:测试用例就是最好的使用文档,告诉你这个函数该怎么用

我个人习惯:每次提交代码前,先跑一遍单元测试。如果测试没过,绝不提交。这个习惯帮我避免了很多线上事故。

1.2 TDD的红-绿-重构循环

TDD,全称是Test-Driven Development,测试驱动开发。它的核心流程就三个步骤:

红(Red)绿(Green)重构(Refactor)

听起来很简单对吧?但真正做起来,很多人会走偏。

第一步:红(Red)

先写一个会失败的测试。注意,是先写测试,再写代码。这个测试描述了你期望的行为。

// 先写测试
@Test
fun `should return true when password is valid`() {
    val validator = PasswordValidator()
    val result = validator.isValid("Abc123!@#")
    assertTrue(result)
}

这时候运行测试,肯定是红色的——因为PasswordValidator类还不存在。

第二步:绿(Green)

写最少的代码让测试通过。不要想太多,够用就行。

class PasswordValidator {
    fun isValid(password: String): Boolean {
        return password.length >= 8
    }
}

运行测试,变绿了。好,第一步完成。

第三步:重构(Refactor)

现在测试通过了,可以优化代码了。但记住:重构时不能改变外部行为

class PasswordValidator {
    fun isValid(password: String): Boolean {
        return password.length >= 8 && 
               password.any { it.isUpperCase() } &&
               password.any { it.isDigit() }
    }
}

重构完再跑测试,还是绿的。完美。

避坑指南:我曾经犯过一个错误——在「绿」阶段写了很多额外功能。结果测试虽然过了,但代码变得很复杂。记住:只写让当前测试通过的最小代码

1.3 测试金字塔模型

你想想看,如果所有测试都写成端到端测试,会怎样?

跑一次测试要几分钟,甚至几十分钟。而且一旦某个环节出问题,整个测试就挂了,你根本不知道是哪里错了。

这就是为什么我们需要测试金字塔

层级 类型 速度 数量 维护成本
顶层 端到端测试(E2E) 慢(分钟级) 少(5-10%)
中间层 集成测试 中等(秒级) 适中(15-20%)
底层 单元测试 快(毫秒级) 多(70-80%)

说白了,测试金字塔告诉我们:大部分测试应该是单元测试。因为它们快、稳定、容易维护。

我在项目中遇到过这样的情况:团队写了大量UI测试,结果每次UI改动都要改测试,维护成本极高。后来我们把重心移到单元测试上,测试覆盖率从30%提升到85%,而且跑一次全量测试从20分钟降到了3分钟。

1.4 Android测试的分类与定位

Android测试主要分三类:

  • 单元测试(Unit Test):测试单个类或方法,不依赖Android框架。运行在JVM上,速度最快。
  • 集成测试(Integration Test):测试多个组件之间的交互,比如ViewModel+Repository。可能需要模拟Android环境。
  • UI测试(UI Test):测试用户界面行为,比如点击按钮、输入文本。运行在模拟器或真机上,速度最慢。

那它们各自应该放在哪里?

// 单元测试:放在 src/test 目录下
// 运行在JVM,不需要模拟器
class LoginViewModelTest {
    @Test
    fun `login with valid credentials should succeed`() {
        // ...
    }
}

// UI测试:放在 src/androidTest 目录下
// 需要模拟器或真机
class LoginActivityTest {
    @Test
    fun `click login button should navigate to home`() {
        // ...
    }
}

注意:不要把UI测试当成单元测试来写。我见过有人用Espresso测试一个简单的数据解析逻辑,结果测试跑一次要30秒。这种逻辑用单元测试,10毫秒就能搞定。

1.5 我的建议

如果你是刚开始接触测试,我建议你:

  1. 从单元测试开始:先给工具类、数据解析、业务逻辑写测试
  2. 坚持TDD:哪怕只是一个小功能,也试试先写测试再写代码
  3. 不要追求100%覆盖率:80%的覆盖率比100%但全是无效测试要好得多

嗯,这一章就到这里。下一章我们会动手写第一个单元测试,到时候你会真正感受到TDD的魅力。

记住:测试不是负担,而是你的安全网。有了它,你才能放心地重构、大胆地迭代。