写了5年代码,我重新开始认真写单元测试
五年前我刚入行的时候,带我的师兄跟我说:"先把功能跑通,测试后面补。"后来我换了三家公司,这句话的变体我听了无数遍------"先上再说""测试覆盖率先不管""这个需求太急了写不了单测"。每次我都点头,然后心安理得地把测试文件拖进"以后再写"的坟墓里。
直到去年,我在一次线上事故复盘会上被问了一个问题:"这个 bug,单元测试能拦住吗?"
我愣了一下,说:"能。"
然后没人说话。沉默比批评难受。
一、我之前为什么不写单测
说实话,不是因为不知道它重要。每个面试我都能背:单元测试提高代码质量、降低回归风险、支撑重构信心。背得比谁都熟。
但落到键盘上,理由一大堆:
写测试比写业务代码慢。 一个接口我半小时写完,测试要写一小时。产品经理站在背后催排期,你跟他说"我在补测试"?他看你像看一个不务正业的人。
测试太容易过时。 需求一改,测试全红,改测试比改代码还烦。几次之后你就学会了------不写测试,就没有"过时的测试"。
很多代码"测不了"。 深度耦合 Spring Context、依赖十几个 Bean、一个方法里又查库又调 RPC 又发消息。你告诉我怎么单测?Mock 写到怀疑人生,最后测试变成了"验证 Mock 框架正常工作"。
最致命的一条:我的代码不需要测试。 我自己跑过,没问题。QA 也测过,没提 bug。上线了,没炸。那测试是给谁写的?
------你看,每一条都"合理"。这也是为什么五年里我从来没觉得自己在偷懒。我只是"务实"。
二、是什么让我重新捡起来的
转折不是某个顿悟时刻,是几件小事叠在一起。
第一件,我开始维护一个三年前的老服务。改一个字段校验,我花了两天。不是改起来难,是不敢改------不知道改完会炸哪里。我翻遍了代码,没有一个测试。我只能手动跑流程,跑完心里还是没底。最后提了 PR,review 的人问:"你确定没副作用?"我说"应该没有"。他说"应该"不够,我说"那我再加几个 case 手动验一下"。
手动验。三年前的代码,靠人肉回归。我当时突然觉得,这不对。
第二件,团队开始推 AI 编码工具。Copilot 写代码飞快,一天能出过去三天的量。但 bug 也多了------不是那种编译不过的 bug,是那种"看起来对、跑起来偶尔不对"的 bug。竞态、边界、空指针藏在 happy path 后面。
我发现自己花在审查 AI 代码上的时间,比自己写还多。而审查最有效的手段,就是看它有没有配套的测试,以及测试覆盖了什么场景。没有测试的代码,我只能一行一行读,读到眼睛出血。
第三件,也是最直接的一件------连续两周,我每周都在凌晨被告警叫起来。两个是自己的锅,两个是接手别人的锅。每次修完我都想:如果当时有测试,这个不会漏到线上。
三次"如果当时有测试",够了。
三、重新写测试,我发现之前的理解全错了
重新开始之后,我最先纠正的是几个认知偏差。
"测试是验证代码正确性的工具"------错。测试是设计代码的工具。
以前我写完代码再写测试,那叫"验证"。你会发现代码根本不好测------因为它在设计时没有考虑可测试性。依赖写死、方法太长、一个函数干五件事。你硬写测试,就是在跟自己的烂设计搏斗。
现在我先写测试,或者至少先想"这个函数我怎么测它"。这个思考过程会逼着你做几件事:把依赖抽出来、把复杂逻辑拆小、让函数的输入输出变清晰。好测的代码和好的代码,是同一回事。
"覆盖率 80% 以上才叫合格"------错。三条测试顶一百条。
我见过覆盖率 90% 的项目,测试全是 happy path,边界一个没碰。也见过覆盖率 40% 的项目,核心链路、异常分支、并发场景全锁死了。哪个更让人安心?显然是后者。
我现在只问自己一个问题:如果这段代码明天被人改了一行,测试能拦住吗? 能拦住的那行,值得测。拦不住的,覆盖一百遍也没用。
"Mock 一切"------大错特错。
以前我觉得单测就是要隔离一切外部依赖,数据库 Mock、RPC Mock、时间 Mock、连随机数都 Mock。写出来测试跑得飞快,绿得发亮。然后线上炸了,因为 Mock 的行为跟真实依赖根本不一样。
现在的做法是:离业务核心越近,Mock 越少。 领域逻辑用真实对象测,基础设施层才 Mock。集成测试补上 Mock 漏掉的那些"真实世界的不确定性"。单测和集成测不是二选一,是各守各的防线。
四、具体怎么落地的
说几个对我真正有用的实践,不是教科书上的,是踩坑之后的。
从修 bug 开始写测试。 不要试图给整个项目补测试,你会崩溃。每次遇到 bug,修之前先写一个能复现 bug 的测试,让它红;修完让它绿。一个 sprint 下来你就有了一小批"真实防御过 bug"的测试。这批测试比任何覆盖率报告都有说服力。
测试命名用业务语言,不用技术语言。 testUserRegistrationSuccess 不如 shouldRejectDuplicateEmail。后者直接告诉你"这条规则是什么",三个月后回来看一眼就知道在防什么。测试是活的文档,命名就是它的标题。
一个测试只断言一件事。 我以前喜欢一个测试方法里塞五六个 assert,跑通了觉得"效率高"。后来一个 assert 失败了,你得看完所有 assert 才知道哪个挂了,而且后面的 assert 根本没执行到。拆开写,每个测试名字就是它验证的那一条规则。
测试数据用 Builder 模式构造。 不要在每个测试里 new 一个对象然后 set 十几个字段,大部分字段跟当前测试无关。Builder 的默认值给一个"合法可用"的状态,测试里只覆盖你关心的字段。测试代码可读性直接上一个台阶。
CI 里测试挂了等于构建失败,没有"先合再修"。 这条是纪律问题。一旦允许一次"测试红了但先合",后面就会有无数次。规则一旦破过,就再也守不住了。
五、写到现在,最大的变化是什么
不是 bug 少了------虽然确实少了。不是重构有信心了------虽然确实有了。
最大的变化是写代码时的心态。
以前写代码是"写完就跑,跑通就交"。现在写代码是"我先想清楚这个东西怎么被验证是对的"。这个思维顺序一换,代码质量是从源头不一样的。你会在写之前多想一步边界、多想一步异常、多想一步"如果输入是 null 呢""如果并发来了呢"。
测试不是一个交付物,是一种思考方式。 你写不写测试文件,其实不重要。重要的是你写代码时有没有在脑子里跑测试。只不过,把脑子里的测试落到文件里,你才不会忘,别人才知道,三个月后的你才不会坑自己。
五年前我入行时,以为写代码就是写代码。现在我知道,写代码只是软件工程里最小的那一部分。 让代码在三个月后、在别人手里、在凌晨三点的生产环境里还能正确运行,那才是工程。
单元测试不是银弹,它甚至不是最重要的那件事。但它是一个最低成本的起点------从"我跑过了"到"我有证据",从"应该没问题"到"测试过了"。
这句话我五年前就会背。今年才真正懂。