TDD:AI 时代,程序员为什么更需要“先写测试再写代码”?
导读:在 AI 可以几秒钟生成一段代码的时代,真正稀缺的已经不是“把代码写出来”,而是判断代码是否正确、是否稳定、是否值得长期维护。TDD(Test-Driven Development,测试驱动开发)正在从一种开发方法,变成 AI 编程时代的重要安全边界。
很多程序员第一次听到 TDD,都会有类似疑问:
• 需求还没完全想清楚,为什么要先写测试? • 写测试不是增加工作量吗? • AI 都能自动生成代码了,还需要自己写测试吗?
这些问题很合理。TDD 并不是为了让开发者“多写一些代码”,而是通过测试把需求变成可验证的约束,让代码始终围绕真实行为演进。
本文将从 TDD 的基本概念、AI 时代的价值,以及一个完整案例三个方面,带你理解如何把 TDD 用在实际开发中。

一、TDD 到底是什么?
TDD 的全称是 Test-Driven Development,中文通常翻译为“测试驱动开发”。它的核心思想只有一句话:
先写一个失败的测试,再写刚好能让测试通过的代码,最后重构代码。
这个过程通常被概括为经典的 Red-Green-Refactor 循环:
1. Red:先写一个失败的测试
测试不是为了描述实现细节,而是为了描述用户能够感知的行为。
例如,我们要开发一个“购物车计算器”,需求是:
当购物车中有两个单价为 10 元的商品时,总价应该是 20 元。
我们可以先写出测试:
@Test
voidshould_calculate_total_price() {
Cartcart=newCart();
cart.add(newProduct("Keyboard", 10), 2);
assertEquals(20, cart.totalPrice());
}此时 Cart 类甚至可能还不存在,测试自然会失败。这就是 Red 阶段。
2. Green:写最少的代码让测试通过
接下来只实现满足当前测试所需的最小功能,而不是一开始就设计复杂的折扣系统、库存系统和支付系统。
publicclassCart {
privatefinal List<CartItem> items = newArrayList<>();
publicvoidadd(Product product, int quantity) {
items.add(newCartItem(product, quantity));
}
publicinttotalPrice() {
return items.stream()
.mapToInt(CartItem::subtotal)
.sum();
}
}再次运行测试,测试通过,进入 Green 阶段。
3. Refactor:在测试保护下重构
测试通过不代表代码已经完美。我们可以在不改变外部行为的前提下:
• 提取价格计算对象; • 改善命名; • 消除重复逻辑; • 引入更清晰的领域模型; • 优化集合和异常处理。
重构之后再次运行测试,只要测试仍然通过,就说明外部行为没有被破坏。
这就是 TDD 的关键:测试为重构提供安全网。

二、为什么 AI 时代反而更强调 TDD?
AI 编程工具让代码生成变得非常快,但“生成得快”不等于“正确率高”。AI 更像一个高效率的实现者,而不是天然可靠的需求分析师、架构师和质量负责人。
1. AI 让“写代码”变便宜,让“验证代码”更重要
过去,开发者需要花大量时间编写样板代码。现在,AI 可以帮助我们:
• 生成类、接口和 DTO; • 补全 CRUD 逻辑; • 编写数据库查询; • 生成测试样例; • 重构重复代码; • 快速解释陌生项目。
但 AI 生成的代码仍然可能存在:
• 边界条件遗漏; • 对业务规则理解错误; • 异常路径处理不完整; • 并发和幂等问题; • 空值、时区、精度等隐蔽错误; • 依赖 API 版本不匹配; • 只在“看起来合理”的场景下工作。
在这种情况下,测试不再只是发布前的质量检查,而是约束 AI 输出的重要机制。
AI 负责扩大实现速度,TDD 负责控制实现方向。
2. TDD 能把自然语言需求变成可执行规格
“订单金额要正确”“用户不能重复领取优惠券”“失败后允许重试”这些需求,如果只停留在文档里,就容易产生理解偏差。
而 TDD 要求我们把需求进一步转化为:
• 输入是什么; • 输出是什么; • 哪些条件必须满足; • 哪些异常必须抛出; • 哪些状态不能被改变。
例如:
测试用例一旦写出来,就成为团队、开发者和 AI 都能理解的“行为契约”。
3. TDD 为 AI Agent 提供反馈闭环
如果让 AI 直接修改一个大型项目,最危险的地方不是它不会写代码,而是它可能不知道自己已经改坏了什么。
TDD 可以形成一个稳定的反馈闭环:
在这个流程中,测试结果相当于 AI 的“编译器反馈”:
• 测试失败,说明实现还没有满足约束; • 测试通过,说明至少满足了已定义的行为; • 测试覆盖不足,说明规格仍然不完整; • 测试难写,往往说明设计耦合过深。
4. TDD 与普通开发模式相比,有什么好处?
这里的“普通开发模式”通常指先写完整功能,再在最后补测试,或者主要依靠手工测试验证功能。
TDD 的价值并不只是“测试覆盖率更高”,更重要的是它会反过来影响代码设计:
如果一段代码很难测试,往往不是测试写得不好,而是代码本身承担了太多职责。
三、一个完整案例:用 TDD 开发限流器
下面以一个常见的后端需求为例:开发一个简单的接口限流器。
1. 需求描述
我们要实现一个固定窗口限流器:
• 每个客户端在一个时间窗口内最多访问 3 次; • 第 1、2、3 次访问允许通过; • 第 4 次访问拒绝; • 时间窗口结束后,计数重新开始; • 不同客户端互不影响。
2. 先定义接口
为了让测试表达清晰,先给出一个简单接口:
publicinterfaceRateLimiter {
booleanallow(String clientId);
}注意,这里还没有考虑具体实现。TDD 的第一步不是急着选 Redis、Guava 还是本地 Map,而是先确定行为。
3. 第一个测试:第一次访问应该通过
classFixedWindowRateLimiterTest {
@Test
voidfirst_request_should_be_allowed() {
RateLimiterlimiter=newFixedWindowRateLimiter(3, Duration.ofMinutes(1));
assertTrue(limiter.allow("client-a"));
}
}测试失败,因为 FixedWindowRateLimiter 还不存在。
此时不要直接实现所有功能,只完成最小闭环。
4. 第二个测试:超过次数应该拒绝
@Test
voidfourth_request_should_be_rejected() {
RateLimiterlimiter=newFixedWindowRateLimiter(3, Duration.ofMinutes(1));
assertTrue(limiter.allow("client-a"));
assertTrue(limiter.allow("client-a"));
assertTrue(limiter.allow("client-a"));
assertFalse(limiter.allow("client-a"));
}这个测试比第一个测试更有价值,因为它验证了限流器真正的核心行为。
可能的最小实现如下:
publicclassFixedWindowRateLimiterimplementsRateLimiter {
privatefinalint maxRequests;
privatefinal Duration window;
privatefinal Map<String, Window> windows = newHashMap<>();
privatefinal Clock clock;
publicFixedWindowRateLimiter(int maxRequests, Duration window) {
this(maxRequests, window, Clock.systemUTC());
}
FixedWindowRateLimiter(int maxRequests, Duration window, Clock clock) {
if (maxRequests <= 0) {
thrownewIllegalArgumentException("maxRequests must be positive");
}
if (window.isZero() || window.isNegative()) {
thrownewIllegalArgumentException("window must be positive");
}
this.maxRequests = maxRequests;
this.window = window;
this.clock = clock;
}
@Override
publicsynchronizedbooleanallow(String clientId) {
Instantnow= clock.instant();
Windowcurrent= windows.get(clientId);
if (current == null || now.isAfter(current.expiresAt())) {
windows.put(clientId, newWindow(now.plus(window), 1));
returntrue;
}
if (current.count() >= maxRequests) {
returnfalse;
}
windows.put(clientId, newWindow(current.expiresAt(), current.count() + 1));
returntrue;
}
privaterecordWindow(Instant expiresAt, int count) {
}
}这里有一个很值得注意的设计:使用 Clock 注入时间,而不是在代码中直接调用系统时间。
如果直接使用 Instant.now(),测试必须等待真实时间窗口结束,测试会变慢且不稳定。注入 Clock 后,我们可以在测试中使用固定时钟或可控时钟。
5. 第三个测试:窗口结束后可以再次访问
@Test
voidrequest_should_be_allowed_after_window_expires() {
MutableClockclock=newMutableClock(Instant.parse("2026-01-01T00:00:00Z"));
RateLimiterlimiter=newFixedWindowRateLimiter(
3,
Duration.ofMinutes(1),
clock
);
assertTrue(limiter.allow("client-a"));
assertTrue(limiter.allow("client-a"));
assertTrue(limiter.allow("client-a"));
assertFalse(limiter.allow("client-a"));
clock.advance(Duration.ofMinutes(1));
assertTrue(limiter.allow("client-a"));
}这个测试推动我们暴露了时间依赖,也让实现更容易验证。
6. 第四个测试:不同客户端互不影响
@Test
voidclients_should_have_independent_limits() {
RateLimiterlimiter=newFixedWindowRateLimiter(3, Duration.ofMinutes(1));
assertTrue(limiter.allow("client-a"));
assertTrue(limiter.allow("client-a"));
assertTrue(limiter.allow("client-a"));
assertFalse(limiter.allow("client-a"));
assertTrue(limiter.allow("client-b"));
}这个测试可以防止实现错误地使用全局计数器。
7. 第五个测试:非法参数应该尽早失败
@Test
voidmax_requests_should_be_positive() {
assertThrows(
IllegalArgumentException.class,
() -> newFixedWindowRateLimiter(0, Duration.ofMinutes(1))
);
}参数校验属于边界行为,也应该被测试明确记录下来。
8. 案例中的 TDD 节奏
这个案例的开发顺序不是:
1. 设计一个“完美”的限流器; 2. 一次性写完全部代码; 3. 最后补一堆测试。
而是:
1. 写第一次访问通过的测试; 2. 实现最小代码; 3. 写超过次数拒绝的测试; 4. 增加计数逻辑; 5. 写窗口过期测试; 6. 注入 Clock解决时间依赖;7. 写客户端隔离测试; 8. 重构命名、数据结构和并发控制。
四、AI 参与 TDD 时,推荐这样协作
AI 可以参与 TDD,但不要把它当成“自动驾驶”。更可靠的方式是让人定义行为,让 AI 加速实现。
推荐流程
1. 人先写清楚业务规则:明确输入、输出、异常和边界; 2. 让 AI 提炼测试场景:要求 AI 列出正常、异常、边界和并发场景; 3. 人审核测试:确认测试真的代表业务,而不是只验证实现细节; 4. 让 AI 实现最小代码:限制改动范围,避免一次生成过度复杂的架构; 5. 运行测试并反馈结果:把失败日志交给 AI 分析; 6. 人审查最终代码:重点检查安全性、性能、可维护性和业务语义。
可以使用下面这样的提示词:
请根据以下需求,先不要实现业务代码,只输出可执行测试场景:
需求:每个客户端在 1 分钟内最多访问 3 次,超过后拒绝访问,窗口结束后恢复。
请覆盖:
1. 正常路径
2. 边界条件
3. 非法参数
4. 不同客户端隔离
5. 时间窗口变化
6. 并发场景
测试应该描述外部行为,不要绑定具体实现细节。随后再让 AI 根据已经确认的测试实现代码:
请根据现有测试实现最小代码,使所有测试通过。
要求:
1. 不修改已有测试的业务断言
2. 不引入暂时用不到的复杂依赖
3. 将时间依赖通过 Clock 注入
4. 完成后说明线程安全方面的考虑五、TDD 不是“测试越多越好”
TDD 也存在被误用的情况。以下做法并不是真正有效的 TDD:
• 先写大量与实现细节绑定的测试; • 为了追求覆盖率,测试大量 getter 和 setter; • 测试名称含糊,无法看出业务规则; • 测试依赖真实时间、真实网络和真实数据库; • 测试失败后直接删除测试,而不是分析原因; • 让 AI 自己生成、自己修改、自己“解释通过”,完全没有人工审查。
更好的原则是:
测试行为,而不是测试实现
不推荐:
verify(repository).save(any(OrderEntity.class));这类测试只验证某个方法是否被调用,容易和实现细节耦合。
更推荐:
assertEquals(OrderStatus.PAID, order.status());它验证的是用户真正关心的业务结果。
让测试快速、独立、可重复
一个好的单元测试通常应该:
• 执行速度快; • 不依赖网络; • 不依赖测试执行顺序; • 不依赖开发者本机环境; • 每次运行结果一致; • 失败时能快速定位原因。
适度使用测试金字塔
TDD 最适合驱动稳定的领域逻辑和应用服务。对于 UI 像素级布局、一次性脚本或高度探索性的原型,可以根据成本选择更轻量的验证方式。
六、写给程序员的实践建议
如果你的项目还没有采用 TDD,不必一开始就要求所有模块全部改造。可以从一个边界清晰的小功能开始:
1. 选择一个规则明确的功能,例如价格计算、权限判断、状态流转; 2. 先写 3~5 个最重要的行为测试; 3. 让测试快速运行,并接入 CI; 4. 每次修复线上问题时,先补一个能复现问题的测试; 5. 使用 AI 生成测试候选,但由人确认业务语义; 6. 逐步建立项目自己的测试命名和目录规范。
建议测试名称直接表达行为,例如:
• should_reject_when_stock_is_insufficient• should_not_charge_when_payment_fails• should_allow_retry_after_temporary_failure• should_return_empty_result_when_no_user_matches
当测试名称本身就像一份需求文档时,代码库会变得更容易理解。
结语:在 AI 时代,把“正确”定义清楚
AI 会让程序员写代码越来越快,但速度越快,越需要稳定的反馈机制。
TDD 的核心价值,不是鼓励开发者机械地先写测试,而是要求我们在写实现之前,先回答一个更重要的问题:
这段代码到底应该保证什么行为?
当需求被写成清晰的测试,AI 才有明确的实现目标;当测试可以持续运行,团队才敢于快速迭代;当测试保护着重构,代码才不会因为变化而越来越脆弱。
所以,TDD 并没有因为 AI 出现而过时。恰恰相反:
AI 让实现变得更快,TDD 让快速实现仍然值得信任。
如果你只能从今天开始做一件事,可以从一个小功能开始:
先写一个会失败的测试,然后让代码一点点变正确。
小米招聘内推
交个朋友,进AI交流群

每天分享最新 小米AI 内部培训资料
关注公众号-私信回复:
ai资料:获取AI完整资料包
全家桶:获取激活码
md:获取激活码
关注公众号
夜雨聆风