26、测试反模式与最佳实践

写测试写了几年,我踩过的坑真不少。有些坑一次就记住了,有些坑反复掉进去。今天我把这些经验整理出来,希望能帮你少走弯路。

测试反模式,说白了就是那些看起来有用、实际上害人不浅的写法。我见过太多项目,测试覆盖率挺高,但一跑就崩,或者改一行代码要改二十个测试。这哪是测试,这是给自己上枷锁。

核心观点:测试不是为了凑覆盖率,而是为了让你敢改代码。如果测试让你不敢动代码,那它就是反模式。

常见测试反模式

先说说我遇到最多的几种反模式。嗯,有些我自己也写过,后来才意识到问题。

1. 测试依赖执行顺序

你想想看,测试A往数据库插了一条数据,测试B依赖这条数据才能跑通。这种写法我见过太多次了。一旦测试A失败,测试B也跟着挂,排查起来特别痛苦。

// ❌ 反模式:依赖其他测试的执行结果
@Test
fun testInsertUser() {
    userDao.insert(User("Alice"))
    assertThat(userDao.count()).isEqualTo(1)
}

@Test
fun testQueryUser() {
    // 依赖 testInsertUser 先执行
    val user = userDao.queryByName("Alice")
    assertThat(user).isNotNull()
}

// ✅ 正确做法:每个测试独立准备数据
@Test
fun testQueryUser() {
    userDao.insert(User("Alice"))
    val user = userDao.queryByName("Alice")
    assertThat(user).isNotNull()
}

我曾经在一个项目里发现,测试用例按字母顺序跑就全绿,按随机顺序跑就挂一半。排查了两天才发现是测试之间互相污染数据。从那以后,我要求团队每个测试都必须独立准备和清理数据。

2. 过度Mock

有些人喜欢把所有东西都Mock掉,连一个简单的getter都要Mock。我个人习惯是:只Mock外部依赖(网络、数据库、文件系统),业务逻辑尽量用真实对象。

// ❌ 反模式:连数据类都Mock
val user = mock(User::class.java)
`when`(user.name).thenReturn("Alice")
`when`(user.age).thenReturn(25)

// ✅ 正确做法:直接创建真实对象
val user = User("Alice", 25)

3. 测试中有条件逻辑

测试里写if-else,这是大忌。测试应该是确定性的,输入什么就输出什么。我在项目中遇到过有人写这样的测试:

// ❌ 反模式:测试中包含条件逻辑
@Test
fun testCalculate() {
    val result = calculator.calculate(10, 20)
    if (result > 0) {
        assertThat(result).isEqualTo(30)
    } else {
        assertThat(result).isEqualTo(-1)
    }
}

// ✅ 正确做法:拆成多个测试
@Test
fun testCalculatePositive() {
    assertThat(calculator.calculate(10, 20)).isEqualTo(30)
}

@Test
fun testCalculateNegative() {
    assertThat(calculator.calculate(-10, -20)).isEqualTo(-30)
}

测试代码复用

测试代码也是代码,也需要复用。但复用要讲究方法,不能为了复用把测试搞复杂。

抽取测试工具方法

我常用的做法是把重复的初始化逻辑抽到父类或者扩展函数里。

// 抽取通用的测试工具
open class BaseRepositoryTest {
    protected lateinit var database: TestDatabase
    protected lateinit var repository: UserRepository

    @Before
    fun setUp() {
        database = createInMemoryDatabase()
        repository = UserRepository(database.userDao())
    }

    @After
    fun tearDown() {
        database.close()
    }
}

// 子类直接使用
class UserRepositoryTest : BaseRepositoryTest() {
    @Test
    fun testSaveUser() {
        repository.save(User("Alice"))
        assertThat(repository.count()).isEqualTo(1)
    }
}

我个人建议:复用测试工具类时,注意不要引入太多抽象层。三层以内最好,超过三层你就要考虑是不是过度设计了。

测试命名规范

命名这事,看似小事,其实影响很大。好的命名让你一眼就知道测试在测什么,坏的命名让你得点进去看代码。

命名风格 示例 评价
test[方法名]_[场景]_[期望结果] testCalculate_whenInputPositive_returnsSum ✅ 清晰明了
test[方法名]_[输入]_[输出] testCalculate_10and20_30 ✅ 适合简单场景
test[方法名]1, test[方法名]2 testCalculate1, testCalculate2 ❌ 完全看不出意图
should[期望行为]_when[条件] shouldReturnSum_whenInputPositive ✅ 可读性好

我个人比较喜欢 should[行为]_when[条件] 这种风格。读起来像自然语言,团队成员也容易理解。

测试组织与分层

测试怎么放,也是个学问。我见过把单元测试、集成测试、UI测试全塞在一个目录里的,那叫一个乱。

推荐的项目结构

app/
├── src/
│   ├── main/                    # 生产代码
│   │   └── kotlin/com/example/
│   │       ├── data/
│   │       ├── domain/
│   │       └── ui/
│   ├── test/                    # 单元测试
│   │   └── kotlin/com/example/
│   │       ├── data/
│   │       ├── domain/
│   │       └── ui/
│   └── androidTest/             # 集成测试 & UI测试
│       └── kotlin/com/example/
│           ├── data/
│           ├── ui/
│           └── navigation/

说白了就是:测试目录结构和生产代码保持一致。这样找测试特别方便,不用猜。

测试分层原则

  • 单元测试:测单个类或方法,不依赖外部环境。放在 test/ 目录下。
  • 集成测试:测多个组件协作,可能需要数据库或网络。放在 androidTest/ 目录下。
  • UI测试:测界面交互,使用 Espresso 或 Compose Test。也放在 androidTest/ 目录下。

我的经验:单元测试和集成测试的比例大概在 7:3 左右。单元测试跑得快,能快速反馈问题。集成测试虽然慢,但能发现组件间的问题。两者缺一不可。

测试文档与注释

测试要不要写注释?我的答案是:要,但别写废话。

什么时候该写注释

  • 测试的业务逻辑比较复杂时
  • 测试依赖了某些外部配置或环境变量时
  • 测试的输入数据有特殊含义时
// ✅ 好的注释:解释为什么这么测
@Test
fun testCalculateDiscount() {
    // 用户等级为VIP,且订单金额超过1000元
    // 根据业务规则,应享受8折优惠
    val user = User(level = UserLevel.VIP)
    val order = Order(amount = 1200.0)

    val discount = discountService.calculate(user, order)

    assertThat(discount).isEqualTo(0.8)
}

// ❌ 坏的注释:重复代码在做什么
@Test
fun testCalculateDiscount() {
    // 创建一个VIP用户
    val user = User(level = UserLevel.VIP)
    // 创建一个订单
    val order = Order(amount = 1200.0)
    // 计算折扣
    val discount = discountService.calculate(user, order)
    // 断言折扣为0.8
    assertThat(discount).isEqualTo(0.8)
}

我建议:测试代码本身应该自解释。好的命名加上必要的注释,比大段文档有用得多。文档写再好,没人看也是白搭。

知识体系总览

下面这张图总结了本章的核心内容,你可以对照着检查自己的测试代码。

测试反模式与最佳实践 常见测试反模式 ❌ 依赖执行顺序 ❌ 过度Mock ❌ 条件逻辑 测试代码复用 ✅ 抽取父类 ✅ 扩展函数 ✅ 测试工具类 测试命名规范 📝 方法_场景_期望 📝 should_行为_when 📝 避免无意义命名 组织与分层 📂 单元测试 📂 集成测试 📂 UI测试 核心目标:让测试成为重构的保障,而不是负担 📖 测试文档与注释:解释Why,而不是What

这张图把本章的四个核心模块串起来了。反模式告诉你什么不该做,复用、命名、组织告诉你该怎么做,文档和注释则是锦上添花。

最后提醒一句:测试是写给未来的自己看的。你现在的偷懒,都会变成未来的加班。写测试时多花五分钟想清楚,以后能省五小时排查问题。


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