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)
}
我建议:测试代码本身应该自解释。好的命名加上必要的注释,比大段文档有用得多。文档写再好,没人看也是白搭。
知识体系总览
下面这张图总结了本章的核心内容,你可以对照着检查自己的测试代码。
这张图把本章的四个核心模块串起来了。反模式告诉你什么不该做,复用、命名、组织告诉你该怎么做,文档和注释则是锦上添花。
最后提醒一句:测试是写给未来的自己看的。你现在的偷懒,都会变成未来的加班。写测试时多花五分钟想清楚,以后能省五小时排查问题。
公众号:蓝海资料掘金营,微信deep3321