接手一套别人写了好几年、注释几乎没有、一上线就没停过修的代码,我第一反应不是打开编辑器开冲,而是先给自己提个问:现在哪些地方是改了必出事的?如果你想给遗留代码补单元测试,却不知道从哪下手,这篇文章就是给你准备的一份逐步指南。我会从“为什么难测”讲到工具选型、实操步骤、报错排查,再聊聊最近大家都在尝试的基于LLM的单元测试辅助玩法。适合正要重构老项目、被CI经常挂、或者刚跳槽要接手陌生代码库的工程师。别指望一下午覆盖率冲到80%,但我们可以从一个函数、一个组件开始,让每一次改动都能被测试接住。
1. 遗留代码为什么难测,先搞清楚“敌人”长什么样
1.1 不是代码老就叫遗留,这四类问题才是关键
很多人以为“遗留代码”等于老项目,这是误区。我见过刚上线一年、代码已经没人敢动的系统,也见过跑了十多年、核心逻辑还稳如老狗的系统。真正让你做不了单元测试的,不是年龄,而是四个特征:没有测试、依赖混乱、文档缺失、结构腐化。这四个特征凑齐,代码本身就成了一个大型“黑盒”,看起来在正常工作,但谁也不知道里面哪些行为是刻意保留的,哪些是历史偶然。
给这种项目加测试,本质上不是“补作业”,而是在一场没有地图的公路旅行里先装个GPS。你得先接受一个现实:你无法一下子理解全部业务规则,也不可能一次性把所有类都测完。所以第一步不是写测试,而是识别“最容易翻车”的地方。我通常会先看线上Bug集中在哪些模块,再看每次发版时要人工回归哪些功能,这些地方就是测试保护网的第一批覆盖点。
1.2 三个典型症状:长方法、硬依赖、全局状态
遗留代码难测,翻来覆去就是三个典型症状。
第一是超长方法。一个方法里干了五件事:参数校验、查数据库、算权限、拼返回、打日志。这种代码没法单独测试,因为测试需要覆盖的是内部每一步,但你根本没法把中间步骤抠出来。第二是“硬依赖”,对象内部直接new出数据库连接、文件句柄、外部服务客户端。你测的时候想替换成Mock,但代码根本没有给你留“插入点”。第三是全局状态和静态单例。时间用System.currentTimeMillis()、配置读静态字段、日志用全局单例,一旦测试里出现这些,你就很难控制输入和输出,断言结果自然不稳定。
这三个症状叠在一起,会让测试变成“跑一次挂一次,挂了还不知道挂在哪”。我自己踩过的坑是:测一个看似简单的服务方法,里面用到了new Date(),上午跑通过,下午跑就失败,因为有一个逻辑处理的是“当前时间是否在午夜之后”。最后把时间改成构造函数的Clock参数才稳定下来。你会在第3.3节看到具体怎么做。
1.3 补测试不是为了刷指标,而是给重构上保险
很多人一提给遗留代码补测试,就反问:业务都跑得好好的,我写测试图什么?我通常这样解释:测试不是写给代码看的,是写给“三个月后的你”看的。你一旦开始重构,删掉一个看似没用的set方法、合并两个分支、提取公用的工具函数,都要回答同一个问题:我这次改动破坏了什么?没有测试,答案只能是“点一遍页面看看”,但页面根本覆盖不到所有边界。
所以补测试的真实目的,是让重构从“靠胆量”变成“靠反馈”。有了一张由测试组成的保护网,你才敢把长方法拆短、把硬依赖换成依赖注入、把全局状态改成显式传参。换句话说,测试是重构的安全带,不是限制你开快车的枷锁。理解这一点后,你就知道为什么我不建议先追求覆盖率,而建议大家先测“改动频率最高、一旦出错损失最大”的那批代码。
2. 动手前的基础准备:工具链选型与测试策略
2.1 测试框架先按项目语言定,Vue项目重点关注这几点
工具链选型不需要太折腾,主流项目基本有约定。Java后端我用JUnit 5 + AssertJ + Mockito,三个组合能覆盖绝大多数场景;Node/TypeScript项目优先Jest或Vitest,如果是Vue项目,测试组件还需要配合@vue/test-utils。Python项目就pytest,简单直接。
前端项目里,Vue + 单元测试报错是最常见的热搜词,但大部分报错不是框架问题,而是环境没配对。Vue 2对应@vue/test-utils@1.x,Vue 3对应@vue/test-utils@2.x,版本装错就会出现无数奇怪问题。用Vite驱动测试的话,建议直接上Vitest,它和Vite共享配置,少一层适配。测试环境记得配成jsdom或happy-dom,否则组件里用到window、document时直接报错。这部分我在第4章会逐个拆解,先记结论:选型别花太多时间,能跑通空测试就行。
2.2 先做可测试性体检,把“能测的”和“不能测的”分开
正式写测试之前,我建议先花半天做一次“可测试性体检”。你不需要读懂所有代码,只需要画出依赖关系:哪些类只依赖参数和返回值,哪些类直接依赖数据库、网络、时间、随机数、静态配置。画完之后你会发现,真正适合第一个上手的,通常是那些“纯逻辑”:日期格式化、金额计算、状态机迁移、权限判断、折扣计算。它们输入输出明确,几乎没有外部依赖,写测试最快,能帮你快速建立信心。
反过来,一堆需要Mock数据库、Mock Redis、Mock外部API的业务类,先放一边。不是不能测,而是你还没给它们做乐高式改造前,硬测只会收获挫败感。我习惯把这个清单分成两列:A列是“今天能测的”,B列是“需要先调整设计才能测的”。先集中处理A列,B列等重构到一定阶段后再逐步收编。这样做的好处是,两三天内就能看到一批绿色测试,团队其他人也更容易接受这个方向。
2.3 别急着写正确断言,先用特征测试锁定现状
新人犯的最大错误,是拿到遗留代码后,按自己理解写“正确”断言。比如看到一个订单状态字段,就觉得CANCELLED状态一定不能变成COMPLETED,于是写了个测试,发现竟然能变,然后去改业务代码。这是灾难。遗留代码里很多行为是历史的、隐含的、甚至有Bug,但它一直在线上运行,下游可能已经依赖了这些行为。你要做的不是替产品经理做决定,而是先用测试把“当前行为”固化下来。
这种测试有个专业名:特征测试或近似测试(Characterization Testing)。思路特别简单:给函数一组输入,记录当前实际输出,把“当前输出”作为断言。先别管它是否符合需求文档,只要它跑通且能稳定复现,就说明你成功锚定了现状。等以后产品明确了新预期,你再改断言,让测试告诉你哪些行为会被改变。有了这套保护网,你才敢做真正的重构。
3. 分步实操:从最小可测单元到全链路覆盖
3.1 第一步:搭测试环境,先让一个空测试跑通
不管多复杂的项目,第一步永远是最朴素的:把测试框架装好,让一个空测试从命令行跑通。拿Java Maven项目举例,在pom.xml里加入依赖:
<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <scope>test</scope> </dependency> </dependencies>然后创建一个最简单的测试类,比如ExampleTest.java,里面只写一个void shouldRun() {},执行mvn test。只要控制台出现“BUILD SUCCESS”,环境就算通了。
如果是前端Vue项目,在vite.config.ts里加上Vitest配置:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom', globals: true } })这里的environment: 'jsdom'就是很多人遇到“Vue + 单元测试报错”的救星,没有它,组件里访问document.getElementById会直接TypeError。跑通空测试后,后续所有调试都在这个“能快速反馈”的回路里进行,效率完全不一样。
3.2 第二步:挑一个纯函数当第一个测试对象
环境OK后,别一上来就测页面对应的Service,而是挑一个你一眼看得懂输入的纯函数。比如有个折扣计算器:
public class DiscountCalculator { public double discount(double price, String level) { if ("VIP".equals(level)) { return price * 0.8; } if ("NORMAL".equals(level)) { return price * 0.9; } return price; } }测试只需要覆盖几个关键分支:
class DiscountCalculatorTest { @ParameterizedTest @CsvSource({ "100, VIP, 80.0", "100, NORMAL, 90.0", "100, GUEST, 100.0", "0, VIP, 0.0" }) void shouldCalculateDiscount(double price, String level, double expected) { DiscountCalculator calculator = new DiscountCalculator(); assertThat(calculator.discount(price, level)).isEqualTo(expected); } }为什么用@ParameterizedTest而不是写四个方法?因为这种纯函数的核心价值就是“输入-输出映射”,参数化测试把数据和逻辑分离,以后加一个用户等级,只需要往CsvSource里加一行。这步的目标不是覆盖率,而是建立“修改→跑测试→看反馈”的闭环。当你第一次看到红色变绿色,信心就来了。
3.3 第三步:处理外部依赖,让代码不再“神秘地new”
纯函数测完,就得处理硬依赖了。最典型的是类里直接new了一个DAO或Client。比如:
public class OrderService { private final OrderRepository repository = new OrderRepository(); public double totalAmount(long orderId) { Order order = repository.findById(orderId); return order.getItems().stream().mapToDouble(Item::getPrice).sum(); } }想测试这个类,就得把new OrderRepository()这块“焊死的地方”撬开,改成构造器注入或接口注入:
public class OrderService { private final OrderRepository repository; public OrderService(OrderRepository repository) { this.repository = repository; } }测试时用Mockito传一个替身进去:
class OrderServiceTest { @Test void shouldSumItemsPrice() { OrderRepository repo = mock(OrderRepository.class); Order order = new Order(List.of(new Item(10), new Item(20))); when(repo.findById(1L)).thenReturn(order); OrderService service = new OrderService(repo); assertThat(service.totalAmount(1L)).isEqualTo(30.0); } }这一步看着简单,但做起来阻力最大。因为遗留代码通常不止一个new,可能是三个五个人叠在一起。我建议先做最小改造:只改构造函数签名,不动业务逻辑。只要签名变化不改变调用方行为,风险就可控。改完一个测一个,测完再改下一个。时间依赖也一样,把System.currentTimeMillis()替换成传入的Clock对象,测试里使用固定时间,这样断言再也不会“随缘”了。
3.4 第四步:按叶子到根的路径逐步扩大覆盖
当一个纯函数和一个Service都能测了,后面的路径就清晰了:按“叶子到根”的方式慢慢扩大。叶子包括工具类、模型类、纯计算函数;中间层是Service、Manager、Store;最外层才是Controller、API层、UI组件。越靠近根,依赖越多,测试成本越高,所以别反过来做。
每加一组测试,就完整跑一遍全量测试,确保没有引入新的不稳定用例。你现在可能不会一次测很多,但要坚持“每次提交都是绿色的”。如果碰到一个类实在没法测,比如静态方法调用直接写死在业务代码里,不要硬掰。先在类上标记@Deprecated或TODO,然后通过“间接测试”覆盖它:用一个公开入口驱动内部逻辑,只要断言能反映行为,就算不完美也比没有强。这样迭代几个月后,你会发现原来不敢动的代码,渐渐都在一张测试网里了。
4. 常见报错与排查实录:Vue+单元测试报错专场
4.1 环境类报错:module not found、jsdom没配好
每次一说给Vue项目写单元测试,总会有人被一串报错劝退。最常见的几个跟环境有关。一是Cannot find module '@vue/test-utils',十有八九是没安装或者版本不对。装的时候注意别装错:
# Vue 3 项目 npm install -D @vue/test-utils@2 @vue/vitest # Vue 2 项目 npm install -D @vue/test-utils@1第二个高频报错是TypeError: window is undefined或document is undefined。这个不需要怀疑自己,99%是测试环境配错了。Vitest默认跑在Node环境,没有浏览器对象。解决办法是在配置里加environment: 'jsdom',或者单文件顶部用注释指令// @vitest-environment jsdom。
环境类报错的特点是:项目本身没问题,测试代码也没问题,就是“缝”没对齐。遇到这种问题,先检查版本兼容,再检查环境配置,最后检查运行命令是否跑到了正确的配置文件。把这三板斧走完,八成环境问题都能解决。
4.2 挂载和渲染报错:wrapper找不到元素、插件没注册
组件测试最常见的第二类报错,是wrapper.find('.btn')找不到任何元素,然后冒出“Unable to find selector”之类的提示。这种情况一般不是测试代码写错,而是组件真实渲染结果和你预期不一样。原因很多:可能组件内部有v-if控制元素没显示;可能是<Transition>包裹导致挂载阶段没有立刻渲染;也可能是子组件依赖了全局插件、路由或Pinia,挂载时直接崩掉。
我的建议是先用wrapper.html()打印一下实际渲染结果。看到真实DOM后,很多问题一目了然。如果是全局插件问题,就在挂载前提前配置:
import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' const wrapper = mount(MyComponent, { global: { plugins: [createPinia()] } })看到“Vue + 单元测试报错”这类关键词时,不用慌。把报错信息完整复制下来,先看是不是“找不到”或“无法初始化”,再去检查挂载配置。大部分组件测试报错,最后都指向同一件事:被测组件需要的外部条件没给齐。
4.3 模块依赖报错:循环引用和组件注册问题
遗留代码的模块依赖往往一团乱,写测试时经常触发那些平时根本不报错的隐藏问题。最典型的是循环依赖报错:ReferenceError: Cannot access 'X' before initialization。这是因为测试环境的模块加载顺序和App入口不同,原本在浏览器里能被缓存的循环依赖,在测试里直接炸开。
解决循环依赖有两条路。一是从设计上拆掉循环引用,但这在遗留代码里短期很难做到。二是给测试环境加一层“假”模块隔离。用vi.mock('模块路径', factory)把其中一个方向的依赖替换成Mock,让加载顺序不再互相卡死。
组件注册也类似。一个组件如果依赖了全局注册的第三方组件,测试环境里没有这个全局组件,渲染就会警告或直接失败。这时用global.stubs把第三方组件替身化:
const wrapper = mount(MyComponent, { global: { stubs: { 'complex-table': true } } })这样测试只关心当前组件行为,不会去追子组件的复杂逻辑。
4.4 异步与假时钟报错:Timeout、Ticker不刷新
异步测试是维护测试稳定性的重头戏。很多遗留代码在调用真实请求和定时器,测试里如果不做控制,就会遇到Timeout - Async callback was not invoked within the specified timeout。解决方案有两个方向:一是使用flushPromises()等待微任务队列清空;二是用vi.useFakeTimers()控制定时器。
这里分享一个实战场景:组件初始化时调了setTimeout(() => { this.loaded = true }, 3000)。测试里如果直接await nextTick(),元素永远是加载状态。正确做法:
vi.useFakeTimers() const wrapper = mount(MyComponent) vi.advanceTimersByTime(3000) await nextTick() expect(wrapper.text()).toContain('加载完成')切回真实时钟后,一定要记得vi.useRealTimers(),否则后续测试全部乱套。假时钟这套工具好用,但也是最容易“泄漏”状态的地方,我习惯在afterEach里统一清理vi.restoreAllMocks()。
4.5 快照测试总是挂?先看看是不是快照里混了时间
快照测试是给遗留代码做“特征测试”的好帮手,但它有个坑:如果渲染结果包含时间戳、随机ID、日期字符串,第一次保存快照时可能正常,第二次跑就变了。别人看着“什么都没改”,CI却飘红一片。
解决办法是把动态部分Mock掉。比如组件里显示new Date().toLocaleString(),测试里用vi.setSystemTime(new Date('2024-01-01 12:00:00'))固定时间,或者配置快照序列化器,把动态字段替换成固定占位。另一个策略是少用大范围快照,尽量用“局部断言”去验证关键字段,比如直接断言文案和类名,而不是把整个渲染结果拍下来。快照是防回归的辅助工具,不是重构的替代品,别把它当万能药。
5. 进阶玩法:基于LLM的单元测试辅助
5.1 LLM在遗留代码测试里能干什么,不能干什么
最近圈里都在聊基于LLM的单元测试,我也上手试了不少项目。先说结论:LLM确实能大幅降低写样板代码的疲劳感,但它不是“自动产生正确测试”的工具。最适合它的场景是:从一段遗留方法里生成基本测试骨架、Mock样板、参数化数据,甚至帮你把现有的集成测试拆成更细的单元测试。因为这些工作是重复度高、模式明确的,LLM做起来又快又稳。
但它不能做决策。比如遗留代码里一个字段的含义、一个分支是否符合业务预期,LLM只能“猜”,而且猜错概率不低。如果你把它的输出当最终答案直接提交,等于用AI偏差替换了人工盲区。所以我的定位是:LLM是结对程序员,不是自动答案机。你负责判断业务的“为什么”,它负责干粗活。
5.2 一个可复用的Prompt模板,以及必须做的三道校验
我试了几个风格的Prompt,下面这个模板对“遗留Java方法生成JUnit 5测试”最有效:
请基于下面这个遗留的Java方法生成JUnit 5单元测试: {这里粘贴源码} 要求: 1. 使用JUnit 5 + AssertJ 2. 不要修改被测方法 3. 使用@ParameterizedTest覆盖你能看到的边界条件 4. 对依赖DB的调用使用Mockito mock 5. 只返回测试代码,并逐条说明每个用例的保护意图生成后不是直接复制,我会做三道校验。第一道是“编译/运行校验”,必须真跑一遍,跑不过的原因要看到;第二道是“业务事实校验”,检查断言里的期望值是否符合当前真实行为,最少跑一次现有方法确认输出;第三道是“独立校验”,如果生成的断言只是把方法内部实现照抄了一遍,比如直接复制return price * 0.8去断言,这就是个无效测试,因为它在验证代码自己写自己。优质测试应当能捕捉到行为变化,而不是复读实现。
5.3 把LLM当成结对程序员,而不是自动答案机
在实际工作流里,我是这样用LLM的:先把遗留代码和依赖关系给它,让它生成初稿;然后我执行测试,把失败信息原封不动喂回去,让它修正;等测试通过后,我再逐个读一遍断言,调整参数化数据和Mock范围。这个“人机循环”比一次性生成再人工改更高效,因为LLM能记住上下文,会根据失败信息推断哪个Mock没配好、哪个期望值不对。
不过也要讲清楚边界:涉及敏感业务数据和私有算法时,不要随手贴到不受控的在线服务上。可以用本地部署模型,或者只把脱敏后的骨架代码给它。还有一点很关键:覆盖率数字可以靠LLM冲得很高,但覆盖率不等于测试质量。如果100个断言都不校验真实行为,绿得再漂亮也没用。我在一个老模块里用LLM补过一轮测试,覆盖率从30%干到75%,但真正帮我抓住重构回归的,也只有里面十几条关键断言。所以,工具始终是工具,把关的还是你自己。
在我实际处理过的遗留项目里,最有价值的往往不是某个酷炫框架,而是把时间、随机数、配置这些“隐藏输入”一一变成可注入的参数,然后把当前行为固化成测试。这个过程没有任何魔法,就是一处处抠、一遍遍跑。你可能会在某一天因为一个被测试接住的回归而庆幸,那天你会觉得之前踩过的坑都值了。