测试驱动重构:安全重构技术、测试作为文档、测试驱动设计、遗留代码测试策略、测试与代码质量

说实话,很多团队做重构都翻过车。我见过最惨的一次,同事改了一个看似无害的变量名,结果线上挂了三个小时。为什么?因为没有测试兜底。今天我们就聊聊怎么用测试给重构上「保险」。

安全重构技术:没有测试,别动手

重构的核心原则就一条:行为不变,结构变。但你怎么证明行为没变?靠测试。

我个人习惯把重构分成三步走:

  1. 先写「安全网」——把关键路径的测试补上
  2. 小步修改——每次只改一个逻辑点
  3. 跑测试验证——绿了再继续,红了立刻回退

举个例子,你有个老代码长这样:

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)
}

测试通过了?好,现在你可以放心重构了。改完再跑一遍,绿了说明没改坏。

⚠️ 我曾经犯过的错: 有一次我重构了一个工具类,觉得「这么简单的代码不用测」。结果改完后,另一个模块的排序逻辑全乱了。从那以后,我定了个规矩——重构前必须有一行测试覆盖,哪怕只是最基础的 happy path。

测试作为文档:代码会撒谎,测试不会

你想想看,代码注释是不是经常过期?文档是不是永远跟不上代码?但测试不会。测试是唯一「活着的文档」。

我接手过一个老项目,注释写着「这个方法用来计算折扣」,但实际代码里还做了库存扣减。你敢信?后来我全靠看测试用例才搞懂真正的业务逻辑。

好的测试文档应该具备三个特征:

  • 可读性——测试方法名就是一句话,比如 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 被抽象成接口,方便替换和测试。

遗留代码测试策略:从「不敢改」到「放心改」

遗留代码最大的问题是什么?不是代码烂,是没有测试。没有测试,你就不敢改。不敢改,代码就越来越烂。这是个死循环。

怎么破?我总结了一套「遗留代码测试三步法」:

  1. 先写「特性测试」——从外部接口入手,验证整体行为。比如 API 层、UI 层。
  2. 再拆「依赖」——把数据库、网络等外部依赖 mock 掉,让测试跑得快、跑得稳。
  3. 最后补「单元测试」——等代码重构得差不多了,再补细粒度的单元测试。

举个例子,你有个老 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()))
}

这个测试虽然慢,但它能保证核心流程不坏。有了这层保护,你才能放心地抽代码、加单元测试。

⚠️ 注意: 遗留代码的测试不要追求覆盖率。我见过有人非要给 10 年老项目冲到 80% 覆盖率,结果写了三个月测试,业务需求一个没做。没必要。先保核心路径,其他的慢慢来。

测试与代码质量:不是「测出来的」,是「设计出来的」

很多人觉得测试是「事后检查」,其实不对。测试是质量设计的一部分。

你想想看,一个方法如果很难写测试,通常意味着什么?

  • 依赖太多——需要 mock 一堆东西
  • 职责不单一——一个方法干了三件事
  • 副作用太多——改了这里,影响了那里

这些都是代码坏味道。所以我说,测试是代码质量的「探照灯」。哪里不好测,哪里就有问题。

我有个简单的判断标准:如果一个方法需要超过 3 个 mock 才能测,那这个方法大概率需要重构。

来看个对比:

指标 可测试性差 可测试性好
依赖数量 5+ 个外部依赖 ≤2 个外部依赖
职责 一个方法做 3 件事 一个方法做 1 件事
副作用 修改全局状态 纯函数,无副作用
测试编写时间 30 分钟起步 5 分钟搞定

说白了,好代码天然就好测。如果你发现写测试很痛苦,别怪测试,先看看代码设计是不是有问题。

知识体系总览

下面这张图是我自己整理的「测试驱动重构」知识体系,你可以把它当成一张地图:

测试驱动重构 安全重构技术 • 先写安全网测试 • 小步修改,每次只改一点 • 跑测试验证,绿了再继续 测试作为文档 • 可读性:方法名即文档 • 可执行:随时跑,随时验证 • 可信任:绿了就是对的 测试驱动设计 • 先写测试,再写实现 • 倒逼接口设计 • 降低耦合度 遗留代码测试策略 • 先写特性测试(API/UI) • 再拆依赖(mock外部服务) • 最后补单元测试 测试与代码质量 • 难测 = 坏味道 • 依赖多 = 需重构 • 好代码天然好测 测试不是负担,是重构的「安全带」

这张图把今天讲的内容串起来了。核心就一句话:测试不是负担,是重构的「安全带」。没有测试的重构,就像没有安全绳的攀岩——不是不能爬,但摔一次就完了。

📌 核心要点回顾:

  • 重构前先写测试,小步修改,绿了再继续
  • 测试是最好的文档,比注释靠谱一万倍
  • TDD 驱动的是设计,不是测试本身
  • 遗留代码从特性测试入手,别一上来就搞单元测试
  • 代码好不好测,直接反映了设计好不好

好了,这一章的内容就到这里。记住,测试不是写给别人看的,是写给你自己未来的「保险」。下次重构前,记得先问问自己:测试写了吗?

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