最近这半年,我频繁在技术群里看到两类截然不同的声音。一类是“AI写代码都这么强了,谁还手写测试”,另一类是“没有测试兜底,AI生成的代码根本不敢合进主干”。两种说法我都经历过,实话说,在没有测试保护的老项目里,AI生成代码越快,我反而越慌——因为你无法判断它给出的这坨新逻辑,到底会把哪些隐藏的旧逻辑踩炸。但当我开始用TDD的方式重新组织开发流程后,情况彻底变了。AI负责快速产出实现代码,测试负责当裁判,我负责定规则。这篇文章就围绕这个组合展开,结合Java版的具体实操,重点讲讲在存量项目里怎么把TDD落地,而不是讲那些书本上永远正确、但一到老代码里就失效的教条。
1. 为什么AI编程时代,TDD突然成了“刚需”
1.1 AI生成代码的“自信满满”与“悄悄翻车”
AI编程工具的生成速度确实惊人,过去要写一天的CRUD接口,现在几条提示词就出来了,甚至还能顺手帮你补单元测试。但问题也出在这里:AI是“概率性”地生成代码,它只保证产出看起来像合理的代码,并不保证逻辑真的符合你的业务需求。更麻烦的是,在存量Java项目里,代码之间往往存在大量隐式耦合——一个看似独立的工具方法,底层可能依赖一个全局静态变量,或者一个静态工具类读取了配置文件,或者某个父类的初始化逻辑偷偷改了系统属性。AI看不到这些运行时行为,它只基于训练数据和大致的上下文猜测。所以它生成的代码经常在语法上无可挑剔,一运行就炸,或者在测试环境跑得好好的,一上线就出现诡异的数据错乱。
我见过最典型的一次“AI翻车”是在一个老订单系统里。同事让AI生成一个新优惠券的校验逻辑,AI根据语义推断出了“用户等级高,折扣力度大”的规则,但它不知道这个老项目的等级字段有历史遗留问题:等级为0表示未初始化,同时数据库里还有一部分脏数据用的是-1表示黑名单。结果AI写出来的判断是if (userLevel >= 2)才给折扣,黑名单用户绕过了限制,直接享受了最高折扣。这种问题,靠Code Review很难发现,靠“肉眼读代码”也不容易察觉,但如果有哪怕一个简单的单元测试,先锁定了“黑名单用户不能参与任何优惠”这条规则,AI的代码合进来的时候就会被立刻拦下。这就是AI编程时代测试价值最直观的体现:它不是帮你“找bug”,而是帮你“守住规则的边界”。
1.2 TDD的价值从“防回归”变成“给AI划边界”
以前大家聊TDD,重点都在“提前设计”“保证覆盖率”“防止回归”这些层面。说实话,在业务紧、工期短的时候,这些理由说服力有限——多花时间写测试,老板看不到进展,同事觉得你磨洋工。但AI编程普及之后,TDD的作用发生了微妙而且关键的转变:它变成了人和AI之间的一种“契约”。
你在写测试的时候,实际上是在用可执行代码描述“这段需求应该是什么”。测试通过了,AI的实现才是被认可的;测试不通过,不管AI生成的代码多优雅,都是无效产出。这时候TDD的红灯,就不再是“我还没写实现”的提醒,而是一个清晰的验收门槛。换句话说,TDD让你能把“需求”翻译成AI能理解的、无歧义的行为约束。你不需要费力去描述“请判断用户是否属于黑名单”,你只需要写一个测试assertThrows(ForbiddenException.class, () -> service.applyCoupon(blackUser, coupon)),然后把这个测试丢给AI,AI就知道自己必须保证这个方法抛异常。体验过这种感觉之后,我再也不想回到没有测试的“盲写AI代码”模式了。
1.3 Java生态里,TDD为什么特别适配
这一点很多人没意识到。Java项目普遍有强类型系统、成熟的依赖管理(Maven/Gradle)、以及几乎成为事实标准的测试框架组合:JUnit + Mockito + JaCoCo。这些工具已经稳定存在很多年,社区资料极其丰富。更关键的是,Java的存量项目特别多——很多公司核心业务系统就是Java开发的,跑了好多年,没人敢碰。这类项目的共同特征是:文档缺失、人员流动大、业务规则藏在代码深处。越是这样,越需要一个“先把行为固定下来”的机制。而TDD里的“特征测试(Characterization Test)”,恰好就是干这个的。
特征测试的概念出自Michael Feathers的《修改代码的艺术》。它的思路很简单:对老代码,不要尝试先理解全部逻辑再去改,而是先根据现有行为自动生成一批测试,把“当前行为”锁死。这样你之后无论重构还是让AI加新功能,只要有测试在,就能第一时间发现行为变化。这比追求“高覆盖率”更有现实意义——覆盖率再高,如果测试断言是错的,等于白搭。Java生态里JUnit的参数化测试、Mockito的宽松校验、JaCoCo的行覆盖率,这些组合天然适合做特征测试。
2. 存量Java项目引入TDD的第一步:先给老代码“上保险”
2.1 识别存量项目的“测试负债”
存量项目做测试,最大的障碍不是技术,而是心理。打开一个5000行的ServiceImpl,到处都是new XXX()、StaticLogger、Thread.sleep()、getInstance(),你会本能地觉得“这没法测”。但实际上,存量项目的“测试负债”是可以分层看的。你不需要在第一天就给所有烂代码写测试,只需要找到“接下来要改动最频繁、出事概率最高”的几个模块,先给它们上保险。
我通常用三个标准圈定首批目标。第一,变更频率高:看Git提交历史,哪个文件最近半年被改动次数最多。第二,业务价值大:哪个模块出错会造成资损、客诉或者核心流程中断。第三,耦合程度相对可控:至少它能被实例化,依赖项能用Mockito打桩。满足这三个条件的类,值得花时间搭测试。而那些极度混乱、一坨屎山、连构造函数都私有化的类,建议先不要碰,否则写一个测试能把你逼疯。等团队有了经验和信心,再逐步清点剩余负债。
一个小技巧:可以先跑一遍JaCoCo或者直接看公司已有的覆盖率报表,找到覆盖率为0但又高变更的核心类。这类类就是“测试负债”最集中的地方。
2.2 用“特征测试”困住现有行为,而不是追求正确行为
给老代码写第一种测试,目标不应该是“确认逻辑正确”,而是“确认当前行为不会意外改变”。因为你可能并不知道业务当初为什么这么写——也许那个看似是bug的行为,实际上是某个老客户依赖的“潜规则”。如果你直接写“我认为这里应该怎样”的断言,改出来十有八九会被业务打回来。
特征测试的写法有路数。你先实例化被测类,喂入一组有代表性的输入,把实际的返回值或异常记录下来,然后把这些“实际值”固化成断言。比如老代码里有这么个方法:
public BigDecimal calcDiscount(String userLevel, BigDecimal amount) { if ("gold".equals(userLevel)) { return amount.multiply(new BigDecimal("0.8")).setScale(2, RoundingMode.DOWN); } return amount; }我拿一个userLevel="gold", amount=100.00跑一下,发现返回80.00,我就写:
@Test void shouldReturnEightyPercentForGoldUser() { DiscountService service = new DiscountService(); BigDecimal result = service.calcDiscount("gold", new BigDecimal("100.00")); assertEquals(new BigDecimal("80.00"), result); }看上去很傻对吧?但它的意义在于:以后不管是你自己重构,还是AI生成了一段“优化后的实现”,只要测试通过,你就可以放心地说“行为没变”。如果行为确实需要变,那就先改测试再改代码——这就是从特征测试过渡到TDD的关键一步:测试从“记录现状”变成“表达期望”。
2.3 搭好测试基础设施:JUnit 5、Mockito、JaCoCo
在Java项目里落TDD,基础设施不需要花里胡哨,三样就够:JUnit 5用来写测试,Mockito用来隔离外部依赖,JaCoCo用来监控覆盖率。Maven项目里,pom.xml核心依赖如下:
<properties> <junit.version>5.10.2</junit.version> <mockito.version>5.11.0</mockito.version> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> </dependencies>对应的构建插件,加JaCoCo:
<build> <plugins> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>verify</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> </plugins> </build>测试目录结构用Maven默认的src/test/java就行。别小看这套环境的搭建,很多存量项目连测试依赖都没加过,跑一次mvn test甚至还会触发一堆历史编译错误。你花一个下午把这些基础打好,后续效率翻倍。
3. 手把手实操:对一个典型的Java存量模块做TDD改造
3.1 案例背景:订单计价模块的新需求
为了把刚才的思路串起来,我们模拟一个非常典型的老Java项目场景。假设有一个OrderPriceServiceImpl,负责计算订单总价。原逻辑很简单:订单金额 = 商品总价 + 运费 - 优惠金额。运费规则是排除法写死的:满99包邮,不满收10元。优惠券支持满减,但代码里优惠券的金额是直接从数据库查出来的,类内部用new CouponClient()直接调用远程接口,这个Client内部又依赖一个静态配置类ConfigHolder来读取环境,导致测试极其困难。
现在产品提了一个新需求:增加“企业会员折扣”,企业会员在计算完所有优惠之后,再打95折。按照开发习惯,老手第一反应可能是“这还不简单,我在return之前乘一下0.95不就行了”。但他没有意识到,这个模块已经三个星期没人敢动了,上一次改它的人已经离职,谁都不清楚那个运费规则里是否还藏着别的分支。
在这个背景下,TDD的价值立刻显现:我们要让这段代码的行为,在修改前先变得可见。
3.2 先写一个失败的测试:让需求变成可执行的红灯
写TDD,第一步不是写实现,而是写一个会失败的测试。我们需要把新需求翻译成断言。这里关键要梳理出“企业会员折扣”应该作用在哪一层。从需求描述来看,它应该是计算完所有其他优惠之后的“最后一步”。假设我们不想破坏原方法签名,也不想让调用方感知,最简单的方式是改造calcFinalPrice方法内部逻辑。
但等等,原方法当前直接依赖CouponClient和ConfigHolder,这俩都很难在测试里实例化。想要写测试,第一步得让被测对象变得可测。这里其实就是一个小的重构:我们先用“提取参数”的方式,把依赖塞进来,而不是让方法内部new出来。
这就可以开始“先写测试”了。我先把接口抽出来,设计成依赖注入的样子:
public class OrderPriceService { private final CouponClient couponClient; public OrderPriceService(CouponClient couponClient) { this.couponClient = couponClient; } public PriceResult calcFinalPrice(List<Item> items, String userLevel) { // 实现暂缺 } }然后写一个测试,验证企业会员的95折:
public class OrderPriceServiceTest { @Test void shouldApplyExtra95PercentDiscountForEnterpriseMember() { CouponClient mockClient = Mockito.mock(CouponClient.class); OrderPriceService service = new OrderPriceService(mockClient); List<Item> items = List.of( new Item("A001", new BigDecimal("100.00"), 2) ); Mockito.when(mockClient.fetchAvailableCoupon("U001")) .thenReturn(new Coupon("C001", new BigDecimal("20.00"))); PriceResult result = service.calcFinalPrice(items, "ENTERPRISE"); // 商品总额 200 - 优惠 20 = 180,再打95折 = 171.00 assertEquals(new BigDecimal("171.00"), result.finalAmount()); } }这个测试一开始肯定是红色,因为OrderPriceService的实现还没有写,连编译都过不了。但这正是TDD的红灯:它明确表达了一个可验证的需求。写的时候,我建议要连“结算顺序”也写进断言,否则后面AI很可能把折扣加在运费上。
3.3 让AI来实现,用测试来验收
现在到了AI编程最有意思的部分。你不需要自己手写实现,可以把你的测试代码和需求描述一起喂给AI,让它给出实现。我常用的提示词结构大概是这个样子的:
我有一个Java类OrderPriceService,构造函数接收一个CouponClient。预期行为如下: 1. 商品总价 = sum(price * quantity) 2. 满99包邮,否则运费10元 3. 优惠券从CouponClient.fetchAvailableCoupon(userId)获取,返回的coupon.amount直接抵扣商品总价,但优惠金额不能超过商品总价。 4. 如果userLevel是"ENTERPRISE",则在最终金额基础上再乘0.95,四舍五入保留2位小数。 5. 请根据以下测试代码实现,保证测试全部通过。 [粘贴测试代码]这时候AI通常会在几秒内生成一个看起来像模像样的实现。你把它粘进去,跑一次mvn test。如果你的测试写得好,AI一次通过的概概率并不低;如果没通过,你也不需要自己闷头改,直接把失败信息贴回给AI,告诉它“断言期望是171.00,实际是180.00,说明折扣没生效,请检查是否对最终金额乘了0.95”。这个循环可以反复进行,相当于你在用测试驱动AI不断迭代。
有一点必须提醒:AI生成的实现可能会悄悄引入额外的依赖,比如直接new ConfigHolder(),或者把折扣判断的字符串写成“enterprise”而不是我们约定的“ENTERPRISE”。这时候不要放过,一定要跑一遍完整测试,并检查关键分支。我的习惯是,测试绿了之后,再补一个用例覆盖“非企业会员不受影响”的场景。这样才能把业务规则真正锁死。
3.4 重构:在测试保护下消除坏味道
红灯变绿灯之后,不要停下来。TDD的最后一步是重构。这个阶段,因为有测试兜底,你可以放心动刀。在这个案例里,常见的重构包括:
- 把硬编码的“运费门槛”“折扣率”提取成常量,甚至配置项。
- 把“企业会员95折”从
calcFinalPrice里抽出一个私有方法,或者独立策略类,方便后续扩展更多会员等级。 - 把原本
new CouponClient()的内部创建改成构造参数注入,彻底断开隐藏的静态依赖。 - 如果项目已经用了Spring,还可以进一步改成
@Service+@Autowired,让IoC容器管理。
每做一步重构,就立刻跑一次测试。只要测试全绿,你就可以确信“行为没有变化”。这种安全感,在改老代码的时候是无可替代的。我自己的体会是,没有测试保护的“顺手重构”,十有八九会改坏某个角落;有了TDD保护,再大的重构也敢做。
4. AI编程工具与TDD结合的操作细节
4.1 让AI帮你写测试的正确姿势
我在实际使用中发现,很多人让AI写测试,只是扔一句“帮我写一下OrderPriceService的单元测试”,这大概率会得到一堆低质量的、只覆盖正路径的测试。想让AI生成真正有用的测试,你得给它足够的上下文,尤其是业务规则和边界条件。
一个实用的提示词模板是:
请为下面的Java方法设计单元测试,被测类是OrderPriceService。 已知业务规则: - 商品总价小于99元时运费10元,大于等于99元包邮 - 优惠券金额不能大于商品总价 - 企业会员最终金额打95折,其他会员不打折 - 优惠券接口可能返回null,此时视为无优惠 - 金额保留2位小数,使用RoundingMode.DOWN 请覆盖:正常用例、边界用例(刚好99元)、空优惠券、企业会员、非企业会员、超额优惠券。 输出JUnit 5测试代码,使用Mockito,不要使用多余依赖。这样出来的测试,基本能保证核心分支都有覆盖。但还是要人工过一遍——AI生成的测试经常忽略“业务期望”,比如它可能只断言“返回值不等于null”这种毫无价值的用例。你要把真正有业务含义的断言给进去。
4.2 红-绿-重构循环中的AI角色分配
在TDD三阶段里,AI最擅长的是“绿”和“红灯后的快速迭代”,而不是“红灯”本身。
- 红灯阶段:通常需要人工设计。因为“写一个失败的测试”这个动作,本质上是需求分析和接口设计。你需要判断被测类的边界、入参、返回值、异常。这活儿AI很难代劳,尤其是在老项目里,AI连业务规则都不知道。
- 绿阶段:AI是主攻手。你把测试代码丢给它,让它快速生成实现,然后跑测试。如果失败,再让它迭代。这个过程可以非常快。
- 重构阶段:AI也能帮上忙,但需要你严格控制范围。你可以让AI“用提取常量方式重构以下方法”,然后跑一遍测试。但如果是“优化性能”这样模糊的指令,AI可能会大改结构,导致测试崩盘。最好的做法是给AI划定小步范围,一步一测。
这个分工越清晰,开发效率越高。千万不要把AI当成“需求分析助手”——它能帮你写代码,但很难帮你判断业务到底要什么。业务规则只能靠人的头脑和可执行的测试来表达。
4.3 测试用例的“AI幻觉”防护
测试代码本身也可能被AI“幻觉”污染。我遇到过三种情况。
第一种,AI生成一个测试,方法内部逻辑有问题,但它误以为通过,比如用了错误的Mockito打桩,导致测试虽然通过,但其实压根没走被测逻辑。这种情况要用“变异测试”的思路来防——你故意改坏一行实现代码,看看测试会不会变红。如果测试还是绿的,说明它没测到点子上。
第二种,AI为了让测试通过,可能在测试里过度放宽断言。比如用assertEquals(0, result.size())代替assertTrue(result.contains(expectedUser))。所以我在审查测试代码时,会重点关注“断言是否是精确的业务期望”。
第三种,AI会“制造”不存在的依赖。比如测试里用了某个test-fixture包,但pom.xml里根本没这个依赖,然后它还给你在测试里import一个不存在的类。解决办法很简单:让AI生成的代码必须在当前项目里直接能编译、能跑,任何额外依赖都要显式给出坐标。
5. 存量项目TDD落地的常见问题与排查技巧
5.1 时间不够,写测试太慢,怎么破
这是所有存量项目引入TDD时最现实的阻力。我的答案是分两步走:新代码强制TDD,老代码先加特征测试再改。翻译成行动就是:凡是新建的类、新增的方法,都必须先写测试再写实现;凡是改动老方法,第一步先给这个老方法做“基线测试”,哪怕只是对新的输入输出做几个断言,也要有一个安全网。这样你每次改动都有最低限度的兜底,而不是在一个毫无测试的代码上直接躺平。
另外,不要追求覆盖率100%。对一个老模块来说,80%行覆盖率已经足以保证重构安全。真正重要的是核心分支和行为边界有没有覆盖到。我见过很多团队把JaCoCo门槛设在90%,结果为了凑覆盖率,写一堆无意义的getter/setter测试,反而浪费时间。
5.2 旧代码私有方法、静态方法、new对象,怎么测
这是老Java项目里最大的测试痛点。我的处理顺序是这样的:
- 优先重构:把
new Object()改成构造参数注入,把静态方法调用改成实例方法。TDD本身就会推动这种重构,因为只有可测的代码才容易写测试。 - 第二选择:如果重构成本太高,用Mockito的静态方法mock能力。例如Mockito 5支持
mockStatic(StaticUtils.class),可以针对特定静态方法做桩。 - 救急方案:用“包私有构造器”或反射来创建对象。但这是最后的妥协,因为反射会让测试脆弱且难维护。
这里有一个反直觉的技巧:不要在测试里执着于绕过私有方法,而应该通过公共方法触发私有方法。私有方法的正确性,可以由公共方法的行为来验证。如果你发现一个私有方法很难通过公共方法触发,很可能是这个私有方法承担了太多职责,该提取到新类里了。
5.3 测试不稳定(Flaky Test)怎么办
存量项目里最常见的不稳定测试来源有三个:时间依赖、随机数、并发顺序。比如老代码里可能有System.currentTimeMillis()来判断优惠券是否过期,或者依赖当前日期来计算“满减周期”。这类测试在当天跑是绿的,过几天再跑就红了。
排查方法:先看哪一步产生的随机性。把这个种子或者时间抽出来,通过依赖注入传进去。例如:
public class CouponValidator { private final Clock clock; public CouponValidator(Clock clock) { this.clock = clock; } public boolean isValid(Coupon coupon) { LocalDate today = LocalDate.now(clock); return !coupon.expireDate().isBefore(today); } }测试里传一个固定时间的Clock:
Clock fixedClock = Clock.fixed(Instant.parse("2024-06-01T00:00:00Z"), ZoneId.systemDefault()); CouponValidator validator = new CouponValidator(fixedClock);另一个容易被忽略的点是Mockito的默认“宽松模式”。如果测试没有对某个方法打桩,Mockito默认返回null或false,这会掩盖真实行为。建议在测试里对关键依赖的调用使用verify做一次确认,确保它真的被调用了,避免出现“测试绿了但代码根本没跑那条分支”的情况。
5.4 团队不配合,怎么推
技术问题好解决,人的问题最难。我在团队里推TDD的时候,遇到最多的抵触是“写测试是浪费时间”“AI都帮我写代码了,我还要自己写测试吗”。我不会试图从理论上说服所有人,而是先找一个“high risk”的老模块做试点,展示一个完整的“AI生成代码+测试兜底”的流程。当大家看到AI改完代码后测试疯狂报警,轻松找出隐藏回归时,自然就明白了。
具体落地建议:一是把测试覆盖率接入CI门禁,但门槛要合理,比如新代码行覆盖不低于70%,存量模块先不做要求。二是要求在代码评审时,diff里必须包含对应的测试用例改动。没有测试的代码变更,一律打回。这个规则比喊一百遍“要重视质量”都管用。三是最重要的一点:你自己先做出一个可复制的模板,别人照着抄就可以了。包括pom.xml配置、测试类结构、命名规范、AI提示词模板,都给团队准备好。
另外,我特别建议在团队内部建立一个“TDD代码实验室”:每周指定一个存量模块,大家用同样的需求,比赛谁用最短时间做到“测试全绿且重构清晰”。胜者分享提示词和踩坑记录。慢慢地,TDD就会从“流程要求”变成一种习惯。
在我实际操作的项目里,TDD加AI的组合最有价值的地方,不是测试本身,而是它逼着我在动AI生成代码之前,先把业务规则想清楚。过去我经常是“让AI先写,写完再说”,现在我会先花十分钟写一个测试,把期望钉死,再让AI动手。这两十分钟的投入,往往能省下后面几小时的调试时间。
最后再分享一个小技巧:如果你面对的是一个完全没有测试的老类,不要急着补一堆中看不中用的用例。先找到“下一个要改动的方法”,把当前行为用特征测试锁住,然后按“红灯-绿灯-重构”的方式,逐步引入TDD迭代。每改一个方法,代码就会变得稍微可测一点。时间久了,整个模块的测试覆盖率会像滚雪球一样涨起来。这比一上来就写200个模型层测试有用得多。