测试驱动重构:安全重构技术、测试作为文档、测试驱动设计、遗留代码测试策略、测试与代码质量
说实话,很多团队做重构都翻过车。我见过最惨的一次,同事改了一个看似无害的变量名,结果线上挂了三个小时。为什么?因为没有测试兜底。今天我们就聊聊怎么用测试给重构上「保险」。
安全重构技术:没有测试,别动手
重构的核心原则就一条:行为不变,结构变。但你怎么证明行为没变?靠测试。
我个人习惯把重构分成三步走:
- 先写「安全网」——把关键路径的测试补上
- 小步修改——每次只改一个逻辑点
- 跑测试验证——绿了再继续,红了立刻回退
举个例子,你有个老代码长这样:
fun calculatePrice(items: List<Item>): Double {
var total = 0.0
for (item in items) {
if (item.type == "BOOK") {
total += item.price * 0.9
} else {
total += item.price
}
}
return total
}
你想把它改成用策略模式。别急着改,先写个测试:
@Test
fun `calculatePrice should apply 10% discount for BOOK type`() {
val items = listOf(Item("BOOK", 100.0), Item("TOY", 50.0))
val result = calculatePrice(items)
assertEquals(140.0, result, 0.01)
}
测试通过了?好,现在你可以放心重构了。改完再跑一遍,绿了说明没改坏。
测试作为文档:代码会撒谎,测试不会
你想想看,代码注释是不是经常过期?文档是不是永远跟不上代码?但测试不会。测试是唯一「活着的文档」。
我接手过一个老项目,注释写着「这个方法用来计算折扣」,但实际代码里还做了库存扣减。你敢信?后来我全靠看测试用例才搞懂真正的业务逻辑。
好的测试文档应该具备三个特征:
- 可读性——测试方法名就是一句话,比如
shouldThrowExceptionWhenOrderIsEmpty() - 可执行——随时可以跑,验证当前行为
- 可信任——绿了就是对的,红了就是有问题
说白了,测试就是给代码写的「使用说明书」。而且这份说明书永远不会过期,因为每次代码变更它都会自动校验。
givenUserIsLoggedIn_whenClickCheckout_thenNavigateToPayment()。这样即使不看代码,光看测试列表就能知道系统行为。
测试驱动设计:先写测试,再写代码
很多人以为 TDD 只是「先写测试再写代码」,其实它真正的价值是驱动设计。
为什么?因为写测试倒逼你思考接口。你想想看,如果先写测试,你不得不问自己:
- 这个方法应该叫什么名字?
- 参数应该传什么?
- 返回值是什么类型?
- 异常情况怎么处理?
这些问题想清楚了,代码自然就清晰了。我在项目中实践 TDD 时,发现一个有趣的现象:先写测试的代码,耦合度普遍更低。因为测试需要 mock 依赖,你自然会把依赖抽象出来。
来看个例子。假设你要实现一个订单服务:
// 先写测试
@Test
fun `should create order when inventory is sufficient`() {
val mockInventory = mock<InventoryService>()
`when`(mockInventory.checkStock("item-1", 2)).thenReturn(true)
val service = OrderService(mockInventory)
val result = service.createOrder("user-1", listOf("item-1" to 2))
assertNotNull(result)
assertEquals("CONFIRMED", result?.status)
}
// 再写实现
class OrderService(private val inventory: InventoryService) {
fun createOrder(userId: String, items: List<Pair<String, Int>>): Order? {
val allInStock = items.all { (itemId, qty) -> inventory.checkStock(itemId, qty) }
return if (allInStock) Order(userId, items, "CONFIRMED") else null
}
}
看到没?测试先写,接口自然就设计出来了。而且 InventoryService 被抽象成接口,方便替换和测试。
遗留代码测试策略:从「不敢改」到「放心改」
遗留代码最大的问题是什么?不是代码烂,是没有测试。没有测试,你就不敢改。不敢改,代码就越来越烂。这是个死循环。
怎么破?我总结了一套「遗留代码测试三步法」:
- 先写「特性测试」——从外部接口入手,验证整体行为。比如 API 层、UI 层。
- 再拆「依赖」——把数据库、网络等外部依赖 mock 掉,让测试跑得快、跑得稳。
- 最后补「单元测试」——等代码重构得差不多了,再补细粒度的单元测试。
举个例子,你有个老 Activity 里面塞了 500 行逻辑。别想着一步到位写单元测试,先写个 UI 测试:
@Test
fun `should show error when login fails`() {
val scenario = launchActivity<LoginActivity>()
scenario.onActivity { activity ->
activity.findViewById<Button>(R.id.login_btn).performClick()
}
onView(withId(R.id.error_text)).check(matches(isDisplayed()))
}
这个测试虽然慢,但它能保证核心流程不坏。有了这层保护,你才能放心地抽代码、加单元测试。
测试与代码质量:不是「测出来的」,是「设计出来的」
很多人觉得测试是「事后检查」,其实不对。测试是质量设计的一部分。
你想想看,一个方法如果很难写测试,通常意味着什么?
- 依赖太多——需要 mock 一堆东西
- 职责不单一——一个方法干了三件事
- 副作用太多——改了这里,影响了那里
这些都是代码坏味道。所以我说,测试是代码质量的「探照灯」。哪里不好测,哪里就有问题。
我有个简单的判断标准:如果一个方法需要超过 3 个 mock 才能测,那这个方法大概率需要重构。
来看个对比:
| 指标 | 可测试性差 | 可测试性好 |
|---|---|---|
| 依赖数量 | 5+ 个外部依赖 | ≤2 个外部依赖 |
| 职责 | 一个方法做 3 件事 | 一个方法做 1 件事 |
| 副作用 | 修改全局状态 | 纯函数,无副作用 |
| 测试编写时间 | 30 分钟起步 | 5 分钟搞定 |
说白了,好代码天然就好测。如果你发现写测试很痛苦,别怪测试,先看看代码设计是不是有问题。
知识体系总览
下面这张图是我自己整理的「测试驱动重构」知识体系,你可以把它当成一张地图:
这张图把今天讲的内容串起来了。核心就一句话:测试不是负担,是重构的「安全带」。没有测试的重构,就像没有安全绳的攀岩——不是不能爬,但摔一次就完了。
📌 核心要点回顾:
- 重构前先写测试,小步修改,绿了再继续
- 测试是最好的文档,比注释靠谱一万倍
- TDD 驱动的是设计,不是测试本身
- 遗留代码从特性测试入手,别一上来就搞单元测试
- 代码好不好测,直接反映了设计好不好
好了,这一章的内容就到这里。记住,测试不是写给别人看的,是写给你自己未来的「保险」。下次重构前,记得先问问自己:测试写了吗?