一、从哪开始?先承认一个现实
接手一套没人敢动的老代码,就像搬进一间堆满杂物的老房子。表面上还能住,但你想改造一下卫生间,却怕砸掉的墙砖连着整面承重墙一起倒。TDD(测试驱动开发)说的道理大家都懂:先写测试,再写实现,让代码永远有安全网。可面对一堆几百行的大函数、藏在全局变量里的状态、动不动就连数据库甚至网线的逻辑,你连第一个测试都不知道写在哪。
这不是你能力不够,而是老代码本身就不是为测试而生的。几十年前写代码的时候,没有“可测试性”这个概念,大家追求的是“能跑就行”。所以,第一步根本不是纠结框架选Jest还是Mocha,而是先找到一个足够小、足够安全的切入点。只要你能把一个不依赖任何外部环境的小函数拿下来,测试的大门就算被你撬开了一道缝。
二、为什么老代码这么难写测试
2.1 变量满天飞,函数千行长
老代码里最常见的场景:一个函数从第10行写到第300行,中间定义了二十多个变量,循环套着循环,if里还藏着if。你想对它写测试,得先手算出它到底有多少条执行路径,这工作量比重写一遍还大。更头痛的是,函数内部直接操作全局变量,测试里不小心一改,就把别的模块的状态也带偏了。
2.2 网络、数据库、全局变量全都绑在一起
很多老函数一边查数据库,一边调第三方接口,完事儿还要写文件。你写测试的时候不可能真的连数据库,也不可能让测试因为网线断了就失败。这些外部依赖就像密密麻麻的蜘蛛网,把业务逻辑缠得死死的。不把它们剥开,测试根本无从下手。
2.3 测试环境根本搭不起来
有些老项目是十年前用老版本框架建的,连依赖都没法在新环境里装全。你光是把项目跑起来就花了一天,更别提写什么测试。这种时候硬上TDD只会让人更绝望。所以,TDD在遗留代码库里的落地,不是方法论的问题,而是“从哪里下刀”的问题。
三、第一个测试应该选哪个函数
3.1 找“纯函数”下手
什么叫纯函数?翻译成大白话就是:同样的输入,永远得到同样的输出,不碰外面任何东西。没有全局变量,不读文件,不查数据库,不发网络请求。这种函数在老代码里虽然少,但一定有。比如一个计算折扣的工具函数、一个格式化手机号的函数、一个把日期转成中文的函数,都属于这个范畴。
下面是一个典型的例子,技术栈是 JavaScript(Node.js 环境)。假设老代码里有一个计算会员折扣的小函数:
// 计算商品折扣后的价格
function calculateDiscount(price, isMember) {
// 不是会员,原价返回
if (!isMember) {
return price;
}
// 会员统一打九折
return Math.round(price * 0.9 * 100) / 100;
}
这个函数不依赖任何外部变量,传入 100 和 true,永远是 90。这就是你最该找的第一块“试验田”。给它写测试,不需要启动数据库,不需要登录系统,只需要跑一下测试命令,就能立刻看到结果。
3.2 从工具函数开始最稳妥
除了纯函数,老代码里的工具类函数也是很好的突破口。比如日期格式化,通常是从某个公共文件里复制出来的老逻辑。下面这个函数用来把日期格式化成“年-月-日”:
// 将 Date 对象格式化为 YYYY-MM-DD 字符串
function formatDate(date) {
const year = date.getFullYear();
// getMonth() 返回的月份是从 0 开始的,所以要加 1
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
注意这里注释解释了 getMonth() 的坑,这正是老代码里最容易出错的地方。如果你能先把这种函数的测试补上,以后别人重构它时,心里就有底了。这种“从工具函数开刀”的思路,其实非常符合测试金字塔里说的:底层的小单元测试成本最低、跑得最快,收益也最稳定。
四、老代码不听话?想办法撬开一条缝
可实际情况是,你找到的函数往往不是纯函数,它偷偷用了全局变量或者内部硬编码了一个文件路径。这时候不要硬写测试,而是先用“小改造”把依赖剥开。
4.1 把依赖变成参数
假设老代码里有一个函数,它内部直接访问了一个全局变量 config,这个 config 从配置文件里读出来,测试时很难初始化。改造很简单:把 config 变成参数传进来。先看改造前的样子:
// 根据全局配置计算运费
function calcFreight(weight) {
// 每公斤运费来自全局 config,测试时很难控制
return weight * config.freightPerKg;
}
改造后,把 config 挪到参数位置:
// 根据传入的配置计算运费,测试时可以直接传一个假配置
function calcFreight(weight, config) {
// config 不再是隐式依赖,而是显式参数
return weight * config.freightPerKg;
}
这种做法有个熟悉的专业名字叫“依赖注入”,但你别被吓到,它的本质就是“别自己偷偷去拿东西,让人从门口递进来”。测试时,你只需要传一个简单的假配置,就能稳定地测试运费计算逻辑。
4.2 从大函数里抽一小段出来
如果一个大函数实在太长,先别想着整个测它。你可以把函数里最容易出错的那一小段逻辑抽出来,变成一个新的纯函数,再给这个新函数写测试。比如,老代码里有一段计算“超额累进税费”的逻辑,藏在一个一百多行的订单处理函数深处。你可以把它抽出来:
// 根据收入区间计算税费,收入越高税率越高
function calcTax(income) {
// 第一档:每月收入 5000 以下不收税
if (income <= 5000) {
return 0;
}
// 第二档:超过 5000 的部分收 10%
if (income <= 20000) {
return (income - 5000) * 0.1;
}
// 第三档:20000 以上,直接按固定速算扣除数简化计算
return 1500 + (income - 20000) * 0.2;
}
这个抽取动作本身是安全的,因为你只是把一段没有外部依赖的运算搬了个家,没有改变任何行为。搬完家,立刻为它补上测试。这样,老代码的“内脏”虽然还没完全清理,但至少有一小块地方,已经被测试的安全网罩住了。
五、让测试跑起来的具体姿势
5.1 选用最简单的测试工具
老代码项目里,你肯定不想为了跑一个测试先安装一堆依赖。好在 Node.js 从 18 版本开始内置了 node:test 模块,不需要额外安装任何包。也就是说,只要你项目里能运行 node --version,就能跑测试。这能避免很多“环境压根装不起来”的麻烦。
5.2 写好你的第一个测试文件
假设你有一个 discount.js 文件,里面是之前那个 calculateDiscount 函数。现在,在项目里新建一个 discount.test.js 文件,内容如下:
// discount.test.js
// 使用 Node.js 内置的测试模块,技术栈:JavaScript / Node.js
const { test } = require('node:test');
const assert = require('node:assert');
// 引入我们想测试的纯函数
const { calculateDiscount } = require('./discount');
// 第一个测试:会员价格正确
test('会员购买100元商品,实付90元', () => {
// 传入 price=100,isMember=true,期待结果是 90
assert.strictEqual(calculateDiscount(100, true), 90);
});
// 第二个测试:非会员价格不变
test('非会员购买100元商品,实付100元', () => {
// 传入 price=100,isMember=false,期待结果是 100
assert.strictEqual(calculateDiscount(100, false), 100);
});
// 第三个测试:价格带小数时,精确到分
test('会员购买88.8元商品,实付79.92元', () => {
// 88.8 * 0.9 = 79.92,Math.round 后仍为79.92
assert.strictEqual(calculateDiscount(88.8, true), 79.92);
});
你看,这个测试文件写得像给人看的说明文档,每个测试都描述了“输入什么、输出什么、为什么”。这就是测试该有的样子:它不只是验证代码正确,更是告诉后来的人,这个函数的行为到底是怎么约定的。
5.3 跑一次,看绿灯
在终端里运行下面这段命令,就能看到测试结果:
# 运行当前目录下所有以 .test.js 结尾的测试文件
node --test discount.test.js
如果一切正常,你会看到类似“pass 3”的输出。这时,你已经在遗留代码库里成功点燃了第一盏绿灯。接下来要做的事情很简单:复制这个模式,找到下一个能安全测试的小函数,继续写。
六、应用场景、优缺点、注意事项
6.1 适合优先写测试的场景
不是所有老代码都值得马上去碰。最适合用这套“先撬小缝”策略的场景,通常有以下几个:
- 每天被很多人调用,但一旦出错就要半夜起来修的工具函数。
- 内部逻辑涉及复杂计算,比如折扣、税费、汇率换算。
- 即将被重构,但当前没有测试兜底的核心模块。
- 团队成员流动大,新同学需要快速理解线上行为的地方。
在这些场景里,每多一个测试,就相当于给重构工作买了一份保险。
6.2 这种做法的优点和缺点
优点是显而易见的:成本低、见效快、不需要大动干戈。你不需要把整个系统拆了重写,也不需要先引入重量级测试框架。只要从一个小函数开始,就能让团队重新体会“写完测试后的那种安心感”。
缺点呢?也很真实。你只是覆盖了零散的小函数,那些最难测的大泥球、外部依赖密集的代码,依然没有被测试保护到。如果就此满足,很容易产生“我们有测试了”的错觉。所以,这种方案适合作为起点,而不是终点。
6.3 注意事项
别在测试里“强测”那些需要真实环境的代码。如果非要测,就给它们加一层薄薄的隔离层。另外,写测试时不要为了写而写,测试要描述真实行为,而不是把代码原样抄一遍。更重要的是,每改完一处老代码,立刻把测试补上,不要让技术债越欠越多。记住,TDD 在遗留代码里的目标不是一步到位,而是让安全区一点点扩大。
七、总结
在遗留代码库里落地 TDD,本质上是一场“从废墟中重建安全感”的过程。别指望一天内给所有老代码都加上测试,也别指望能先把整个系统整理干净再动手。你要做的,是从一个不依赖外界的纯函数开始,或者从一个隐蔽的小逻辑里抽出一段可以独立运算的代码,用 Node.js 内置的测试模块写出生平第一个测试,然后看着它通过。这个过程会给你一个清晰的箭头:下一块该拆哪里、下一个测试该写哪里。当绿色指示灯第一次亮起的瞬间,你会发现,老房子并没有想象中那么可怕,只要你能找到那堵最薄的墙,亲手把它凿开一道缝,光就会照进来。
评论
围绕“TDD在遗留代码库中的落地困境:从何处开始编写第一个测试”参与讨论