遗留代码单元测试实战:从难测到可测的完整路径
发布时间:2026/10/10 17:53:45 锦皓数字建站

接手一套别人写了好几年、注释几乎没有、一上线就没停过修的代码我第一反应不是打开编辑器开冲而是先给自己提个问现在哪些地方是改了必出事的如果你想给遗留代码补单元测试却不知道从哪下手这篇文章就是给你准备的一份逐步指南。我会从“为什么难测”讲到工具选型、实操步骤、报错排查再聊聊最近大家都在尝试的基于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-utils1.xVue 3对应vue/test-utils2.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 groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId scopetest/scope /dependency dependency groupIdorg.assertj/groupId artifactIdassertj-core/artifactId scopetest/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-utils2 vue/vitest # Vue 2 项目 npm install -D vue/test-utils1第二个高频报错是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%但真正帮我抓住重构回归的也只有里面十几条关键断言。所以工具始终是工具把关的还是你自己。在我实际处理过的遗留项目里最有价值的往往不是某个酷炫框架而是把时间、随机数、配置这些“隐藏输入”一一变成可注入的参数然后把当前行为固化成测试。这个过程没有任何魔法就是一处处抠、一遍遍跑。你可能会在某一天因为一个被测试接住的回归而庆幸那天你会觉得之前踩过的坑都值了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。