乐于分享
好东西不私藏

TDD:AI 时代,程序员为什么更需要“先写测试再写代码”?

TDD:AI 时代,程序员为什么更需要“先写测试再写代码”?

TDD:AI 时代,程序员为什么更需要“先写测试再写代码”?

导读:在 AI 可以几秒钟生成一段代码的时代,真正稀缺的已经不是“把代码写出来”,而是判断代码是否正确、是否稳定、是否值得长期维护。TDD(Test-Driven Development,测试驱动开发)正在从一种开发方法,变成 AI 编程时代的重要安全边界。

很多程序员第一次听到 TDD,都会有类似疑问:

  • • 需求还没完全想清楚,为什么要先写测试?
  • • 写测试不是增加工作量吗?
  • • AI 都能自动生成代码了,还需要自己写测试吗?

这些问题很合理。TDD 并不是为了让开发者“多写一些代码”,而是通过测试把需求变成可验证的约束,让代码始终围绕真实行为演进。

本文将从 TDD 的基本概念、AI 时代的价值,以及一个完整案例三个方面,带你理解如何把 TDD 用在实际开发中。

在这里插入图片描述

一、TDD 到底是什么?

TDD 的全称是 Test-Driven Development,中文通常翻译为“测试驱动开发”。它的核心思想只有一句话:

先写一个失败的测试,再写刚好能让测试通过的代码,最后重构代码。

这个过程通常被概括为经典的 Red-Green-Refactor 循环:

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:生成测试
AI:实现代码
运行测试
AI:分析失败原因
人:审查设计与业务语义
合并代码

在这个流程中,测试结果相当于 AI 的“编译器反馈”:

  • • 测试失败,说明实现还没有满足约束;
  • • 测试通过,说明至少满足了已定义的行为;
  • • 测试覆盖不足,说明规格仍然不完整;
  • • 测试难写,往往说明设计耦合过深。

4. TDD 与普通开发模式相比,有什么好处?

这里的“普通开发模式”通常指先写完整功能,再在最后补测试,或者主要依靠手工测试验证功能。

对比维度
先开发后测试
TDD
需求理解
容易停留在模糊描述
提前转化为可验证行为
反馈时间
通常在功能完成后
每完成一个小步骤就反馈
缺陷发现
可能晚到集成测试或线上
更早暴露在开发阶段
代码设计
容易形成高耦合结构
倾向于小模块、低耦合设计
重构成本
担心改坏功能而不敢改
有测试保护,重构更有信心
AI 协作
AI 输出难以验证
测试成为明确的验收边界
回归测试
依赖人工重复操作
可自动化、可持续执行

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. 1. 设计一个“完美”的限流器;
  2. 2. 一次性写完全部代码;
  3. 3. 最后补一堆测试。

而是:

  1. 1. 写第一次访问通过的测试;
  2. 2. 实现最小代码;
  3. 3. 写超过次数拒绝的测试;
  4. 4. 增加计数逻辑;
  5. 5. 写窗口过期测试;
  6. 6. 注入 Clock 解决时间依赖;
  7. 7. 写客户端隔离测试;
  8. 8. 重构命名、数据结构和并发控制。
实现测试开发者实现测试开发者写一个最小失败测试Red:测试失败编写最小实现运行测试Green:测试通过重构并保持行为不变回归测试继续下一个行为

四、AI 参与 TDD 时,推荐这样协作

AI 可以参与 TDD,但不要把它当成“自动驾驶”。更可靠的方式是让人定义行为,让 AI 加速实现。

推荐流程

  1. 1. 人先写清楚业务规则:明确输入、输出、异常和边界;
  2. 2. 让 AI 提炼测试场景:要求 AI 列出正常、异常、边界和并发场景;
  3. 3. 人审核测试:确认测试真的代表业务,而不是只验证实现细节;
  4. 4. 让 AI 实现最小代码:限制改动范围,避免一次生成过度复杂的架构;
  5. 5. 运行测试并反馈结果:把失败日志交给 AI 分析;
  6. 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());

它验证的是用户真正关心的业务结果。

让测试快速、独立、可重复

一个好的单元测试通常应该:

  • • 执行速度快;
  • • 不依赖网络;
  • • 不依赖测试执行顺序;
  • • 不依赖开发者本机环境;
  • • 每次运行结果一致;
  • • 失败时能快速定位原因。

适度使用测试金字塔

少量:端到端测试\n验证关键用户流程
适量:集成测试\n验证模块与基础设施协作
大量:单元测试\n验证领域规则与边界行为

TDD 最适合驱动稳定的领域逻辑和应用服务。对于 UI 像素级布局、一次性脚本或高度探索性的原型,可以根据成本选择更轻量的验证方式。


六、写给程序员的实践建议

如果你的项目还没有采用 TDD,不必一开始就要求所有模块全部改造。可以从一个边界清晰的小功能开始:

  1. 1. 选择一个规则明确的功能,例如价格计算、权限判断、状态流转;
  2. 2. 先写 3~5 个最重要的行为测试;
  3. 3. 让测试快速运行,并接入 CI;
  4. 4. 每次修复线上问题时,先补一个能复现问题的测试;
  5. 5. 使用 AI 生成测试候选,但由人确认业务语义;
  6. 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 让快速实现仍然值得信任。

如果你只能从今天开始做一件事,可以从一个小功能开始:

先写一个会失败的测试,然后让代码一点点变正确。

小米招聘内推

部门
岗位名称
拟定需求职级
base地
AI应用研发中心
大模型算法工程师-研产供AI
17
北京
AI应用研发中心
大模型算法专家-情景演练
17
北京
AI应用研发中心
大模型算法-北京
17
北京
AI应用研发中心
AI算法工程师
16
武汉
AI应用研发中心
大模型高级算法工程师(武汉)
16/17
武汉
研产供数字化部
高级产品经理
16
北京
研产供数字化部
【IPD项目管理】低代码平台高级产品经理
17
北京
研产供数字化部
【IPD数字化】用户体验高级产品经理
17
北京/武汉
研产供数字化部
【IPD项目管理】AI集成与应用高级产品经理
17
北京
研产供数字化部
解决方案架构师
17
北京
研产供数字化部
架构师及专家
17
武汉
研产供数字化部
测试工程师--支持IPD业务测试
17
武汉
研产供数字化部
高级软件研发工程师
17
武汉
研产供数字化部
供应链数字化解决方案专家
17
武汉
技术发展与质量管理部
高级软件研发工程师
17
北京
技术发展与质量管理部
平台型产品专家-研发效能方向
17
北京
技术发展与质量管理部
效能研发工程师
17
北京
技术发展与质量管理部
高级后端研发工程师
17
武汉
零售研发部
交易产品经理
17
北京
零售研发部
交易产品专家
17
北京
零售研发部
高级运营产品经理
17
北京
零售研发部
高级测试开发工程师
17
武汉
服务研发部
后端研发工程师
17
武汉
服务研发部
后端研发工程师
17
武汉
服务研发部
技术专家
17
武汉
服务研发部
高级软件开发工程师
17
武汉
中国区销服数字化部
数据产品经理
17
北京
国际销服数字化部
高级零售产品经理
17
北京
国际销服数字化部
CRM产品经理
16
北京
国际销服数字化部
国际-仓储物流产品经理
16
北京
交付履约部
进销存产品经理
17
武汉
交付履约部
Java 后端研发工程师
17
武汉
交付履约部
JAVA技术专家
17
武汉
交付履约部
JAVA技术专家(供应链方向)
17
武汉
交付履约部
供应链高级JAVA工程师
17
武汉
汽车销交服数字化部
汽车出海销交服数字化产品专家
17
北京
汽车销交服数字化部
汽车零售数字化产品经理
17
北京
企业效率部
财务xAI产品经理
17
北京
企业效率部
财务产品经理-(财务运营提效方向)
17
武汉
企业效率部
人力xAI产品经理
17
北京
企业效率部
高级产品经理(协同基建方向)
16-17
武汉
企业效率部
高级产品经理(资产、行政方向)
16-17
武汉
企业效率部
高级软件开发工程师(财经系统)
17
武汉
作战室
文化运营专员
14-15
北京
战略规划与运营部
高级用户体验设计师
17
北京
数据部
Java开发工程师
17
北京
数据部
大数据开发工程师(国际)
17
北京
数据部
大数据开发工程师(中国区)
17
北京
数据部
大数据开发工程师(汽车)
17
北京
数据部
大数据开发工程师(研产供)
17
北京
数据部
大数据开发工程师(中国区)
17
武汉
数据部
大数据研发工程师(设备)
17
武汉
数据部
高级软件研发工程师
17
武汉
数据部
前端开发工程师
17
武汉

交个朋友,进AI交流群

每天分享最新 小米AI 内部培训资料

关注公众号-私信回复:

ai资料:获取AI完整资料包

全家桶:获取激活码

md:获取激活码

关注公众号