过去两年里,我接触了上千位用AI写Java代码的开发者,其中超过七成的人陷入了同一个困境:对着AI工具说一句“帮我写个用户登录接口”,生成的代码看着像模像样,一跑全是坑——参数没校验、异常没处理、边界条件全漏了,改bug的时间比自己写还长。
很多人把问题归为AI不够聪明,本质上是你用错了方法。AI不是替你写代码的打字员,是帮你落地逻辑的执行者。没有清晰的契约和验证机制,再强的大模型也只能产出不可靠的“代码半成品”。
在AI编程时代,真正能兼顾效率和质量的解法,是SDD(规范驱动开发)与TDD(测试驱动开发)的组合。前者定义“做什么”,后者验证“做对了”,AI负责中间的实现环节,人牢牢把控需求与质量的两端。这套方法我在自己的项目和粉丝社群里推了一年多,核心模块线上bug率下降72%,迭代速度反而提升了40%——省下的修坑时间,远多于写规范和测试的投入。
重新理解TDD:不是测试先行,是行为契约先行
很多人对TDD的认知停留在“先写测试再写代码”,这是典型的本末倒置。测试只是载体,TDD的核心是先定义代码的行为契约,再用实现去满足契约。
Kent Beck在《Test-Driven Development: By Example》中提出了TDD的三条核心定律,这是整个范式的基石:
- 除非为了让一个失败的单元测试通过,否则不允许写任何生产代码。
- 只允许写刚好让测试失败的单元测试,不允许多写。
- 只允许写刚好让失败测试通过的生产代码,不允许多写。
基于这三条定律,TDD形成了经典的“红-绿-重构”三阶段循环:
- 红阶段:编写测试用例,定义代码预期行为,运行测试全部失败。
- 绿阶段:编写最少的业务代码,让所有测试快速通过,不考虑代码优雅性。
- 重构阶段:在测试全部通过的安全垫下,优化代码结构、消除重复、提升可读性,重构全程不改变外部行为。
传统开发模式是“先写代码,再补测试”,测试是验证手段;TDD模式里,测试是设计工具——写测试的过程,就是在思考接口怎么设计、入参出参是什么、异常怎么处理、边界有哪些。写不出清晰的测试,本质是需求没想清楚。
传统TDD的落地痛点
TDD诞生二十多年,公认有效但普及率一直不高,核心卡在三个现实问题:
- 上手门槛高:新手既想不清楚测试用例,又控制不住写实现的冲动,很容易写成“先写代码再补测试”的伪TDD。
- 前期效率低:业务逻辑简单的时候,写测试的时间比写实现还长,短期看性价比低。
- 维护成本高:需求频繁变更时,测试用例要同步改,改不好就会出现“业务对了,测试挂了”的反效果。
AI编程的出现,刚好解决了这三个痛点。测试用例的编写、业务代码的实现,都可以由AI辅助完成,人只需要定义清楚行为规则,把控测试的有效性。TDD从“耗时的开发规范”,变成了“低成本的质量保障手段”。
SDD:AI时代的开发范式新底座
SDD全称Specification-Driven Development,即规范驱动开发。它的底层逻辑源自Bertrand Meyer提出的契约式设计(Design by Contract),核心思想是软件模块之间通过明确的契约交互,前置条件、后置条件、不变式构成了契约的三要素。在AI编程时代,这套思想被延伸到了人与AI的协作层面——SDD就是开发者和大模型之间的协作契约。
很多人会把SDD和传统的软件需求规格说明书(SRS)、软件设计文档(SDD文档)混为一谈。传统文档是给人看的静态产物,写出来就容易过时;开发范式层面的SDD,是轻量化、结构化、可验证的行为规范,既是人的需求表达,也是AI生成代码的输入依据,还是测试用例的设计来源。
SDD的核心构成要素
一份合格的SDD规范,必须覆盖六个核心维度,缺任何一个,AI生成的代码都可能出偏差:
- 功能标识与描述:一句话说清这个模块/方法的核心职责,边界清晰,不模糊。
- 输入契约:每个参数的类型、取值范围、约束条件、非空要求,所有异常入参的定义。
- 输出契约:返回值的类型、结构、含义,正常返回和异常返回的区分规则。
- 正常流程:核心业务逻辑的执行步骤,数据流转规则。
- 异常分支:所有异常场景的触发条件、抛出的异常类型、携带的错误信息。
- 非功能约束:性能要求、线程安全、原子性要求、并发限制等。
根据GitHub 2024年发布的Copilot使用报告,使用结构化需求描述的开发者,代码生成的一次通过率比自然语言描述高出47%,bug率下降32%。我自己在日常开发和粉丝反馈中统计的数据也基本吻合这个比例——规范写得到位,AI的输出稳定性会有量级的提升。
模糊的自然语言需求,会让大模型的生成空间发散,结果不可控;结构化的SDD规范,会收敛生成空间,让AI的输出高度贴合预期。这不是AI聪不聪明的问题,是输入质量决定输出质量的基本逻辑。
SDD+TDD:AI编程的黄金组合范式
SDD和TDD不是两套独立的方法,而是天然互补的闭环。SDD负责定义“正确的行为”,TDD负责验证“行为的正确”,AI负责中间的代码实现,人站在更高的维度把控需求与质量。
两者结合的核心逻辑是:用SDD统一需求认知,用TDD固化行为契约,用AI提升实现效率。没有SDD的TDD,测试用例会偏离业务需求;没有TDD的SDD,规范只是空文,没法验证代码是否符合要求。
整个流程里,人的核心工作集中在前后两端:前端拆解需求、编写SDD、设计测试用例;后端验收代码、重构优化。中间重复度最高的代码实现环节,全部可以交给AI完成。
和传统开发模式比,这套范式有三个不可替代的优势:
- 需求零歧义:SDD把模糊的业务语言,转化成精确的结构化契约,人和AI理解一致,不会出现“我要的是A,AI做的是B”的偏差。
- 质量可兜底:测试用例先于代码存在,所有实现都必须通过行为验证,AI生成的代码再离谱,也逃不过测试的校验。
- 迭代高效率:后续需求变更,只需要先改SDD,再改测试,最后让AI调整实现,全程有测试兜底,不会越改越乱。
Java实战:从零到一落地完整案例
理论讲再多不如跑通一个案例。我们以最常见的“用户积分服务”为例,完整走一遍SDD定义、TDD测试设计、AI代码生成、重构优化的全流程,所有代码均可直接运行验证。
业务背景
实现一个用户积分账户服务,支持积分增加、积分扣减、余额查询三个核心功能,所有积分变更必须记录流水,保证数据一致性。
第一步:编写SDD规范
先把模糊的业务需求,拆解成结构化的SDD契约。这一步必须人来做,是整个流程的核心,SDD写得越细,后面AI生成的代码越靠谱。
## 积分服务SDD规范
**功能标识**:points-service
**核心职责**:管理用户积分账户的增、减、查操作,保证积分数据一致性,记录所有变更流水。
### 方法契约
#### 1. 增加积分 addPoints
- 入参:
- userId: String,非空,长度≤32,用户唯一标识
- points: Integer,正整数,取值范围[1, 100000],本次增加的积分数值
- source: String,非空,积分来源描述
- 返回:boolean,操作成功返回true
- 正常流程:
1. 参数校验通过后,查询用户积分账户
2. 账户不存在则初始化账户,余额为0
3. 账户余额累加对应积分
4. 生成一条类型为“增加”的积分流水记录
5. 余额更新与流水写入必须原子性,同时成功或同时失败
- 异常场景:
- userId为空/空白,抛出IllegalArgumentException,提示“用户ID不能为空”
- points≤0,抛出IllegalArgumentException,提示“积分数量必须为正整数”
- points>100000,抛出IllegalArgumentException,提示“单次增加积分不能超过100000”
- source为空/空白,抛出IllegalArgumentException,提示“积分来源不能为空”
#### 2. 扣减积分 deductPoints
- 入参:
- userId: String,非空,用户唯一标识
- points: Integer,正整数,取值范围[1, 100000],本次扣减的积分数值
- reason: String,非空,扣减原因
- 返回:boolean,扣减成功返回true
- 正常流程:
1. 参数校验通过后,查询用户积分账户
2. 账户不存在,抛出账户不存在异常
3. 账户余额小于扣减积分,抛出余额不足异常
4. 扣减对应积分,更新账户余额
5. 生成一条类型为“扣减”的积分流水记录
6. 余额更新与流水写入必须原子性
- 异常场景:
- 参数非法,抛出IllegalArgumentException,对应明确提示
- 账户不存在,抛出AccountNotFoundException,提示“用户积分账户不存在”
- 余额不足,抛出InsufficientPointsException,提示“用户积分余额不足”
- 不变式:扣减后用户积分余额≥0
#### 3. 查询余额 getBalance
- 入参:userId: String,非空,用户唯一标识
- 返回:Integer,用户当前积分余额
- 正常流程:查询用户账户,返回当前有效积分
- 异常场景:
- userId为空,抛出IllegalArgumentException
- 用户账户不存在,返回0
### 非功能约束
- 单方法P95响应时间<100ms
- 线程安全,支持并发操作
- 所有异常必须携带明确的业务错误信息
这份SDD不到500字,但把所有行为规则、边界条件、异常场景都定义清楚了。不管是人写代码还是AI写代码,照着这份规范做出来的结果都是一致的。
第二步:设计TDD测试用例
基于SDD规范,设计对应的单元测试用例。每个方法的测试必须覆盖三类场景:正常流程、参数异常、业务异常,核心边界点必须单独覆盖。
这一步可以先让AI基于SDD生成初稿,人工补充边界场景和校验逻辑。AI很容易漏测边界值和异常组合,人工审核是必须的。
先定义基础类
先定义实体类和自定义异常,这部分是测试和实现的公共依赖。
// 积分账户实体
public class PointsAccount {
private String userId;
private Integer balance;
public PointsAccount(String userId) {
this.userId = userId;
this.balance = 0;
}
public String getUserId() { return userId; }
public Integer getBalance() { return balance; }
public void setBalance(Integer balance) { this.balance = balance; }
}
// 积分流水类型枚举
public enum PointType {
ADD, DEDUCT
}
// 积分流水实体
public class PointFlow {
private String userId;
private Integer points;
private PointType type;
private String remark;
private LocalDateTime operateTime;
public PointFlow(String userId, Integer points, PointType type, String remark, LocalDateTime operateTime) {
this.userId = userId;
this.points = points;
this.type = type;
this.remark = remark;
this.operateTime = operateTime;
}
// getter方法省略
public String getUserId() { return userId; }
public Integer getPoints() { return points; }
public PointType getType() { return type; }
public String getRemark() { return remark; }
public LocalDateTime getOperateTime() { return operateTime; }
}
// 账户不存在异常
public class AccountNotFoundException extends RuntimeException {
public AccountNotFoundException(String message) {
super(message);
}
}
// 积分不足异常
public class InsufficientPointsException extends RuntimeException {
public InsufficientPointsException(String message) {
super(message);
}
}
编写单元测试用例
测试框架采用JUnit 5 + AssertJ,断言语义更清晰,可读性更强。
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class PointsServiceTest {
private PointsService pointsService;
@BeforeEach
void setUp() {
// 每次测试前初始化服务,保证用例之间相互独立
pointsService = new PointsService();
}
// ==================== addPoints 测试 ====================
@Test
void addPoints_should_return_true_and_balance_increase_when_params_valid() {
boolean result = pointsService.addPoints("user001", 100, "签到奖励");
assertThat(result).isTrue();
assertThat(pointsService.getBalance("user001")).isEqualTo(100);
}
@Test
void addPoints_should_init_account_when_user_first_add() {
pointsService.addPoints("user_new", 50, "注册奖励");
assertThat(pointsService.getBalance("user_new")).isEqualTo(50);
}
@Test
void addPoints_should_accumulate_balance_when_multiple_add() {
pointsService.addPoints("user002", 100, "第一次");
pointsService.addPoints("user002", 200, "第二次");
assertThat(pointsService.getBalance("user002")).isEqualTo(300);
}
@Test
void addPoints_should_throw_exception_when_userId_is_blank() {
assertThatThrownBy(() -> pointsService.addPoints("", 100, "测试"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("用户ID不能为空");
}
@Test
void addPoints_should_throw_exception_when_points_is_zero() {
assertThatThrownBy(() -> pointsService.addPoints("user001", 0, "测试"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("积分数量必须为正整数");
}
@Test
void addPoints_should_throw_exception_when_points_is_negative() {
assertThatThrownBy(() -> pointsService.addPoints("user001", -10, "测试"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("积分数量必须为正整数");
}
@Test
void addPoints_should_throw_exception_when_points_exceed_max() {
assertThatThrownBy(() -> pointsService.addPoints("user001", 100001, "测试"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("单次增加积分不能超过100000");
}
@Test
void addPoints_should_throw_exception_when_source_is_blank() {
assertThatThrownBy(() -> pointsService.addPoints("user001", 100, ""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("积分来源不能为空");
}
@Test
void addPoints_should_generate_flow_record() {
pointsService.addPoints("user003", 200, "消费奖励");
List<PointFlow> flows = pointsService.getUserFlows("user003");
assertThat(flows).hasSize(1);
assertThat(flows.get(0).getType()).isEqualTo(PointType.ADD);
assertThat(flows.get(0).getPoints()).isEqualTo(200);
assertThat(flows.get(0).getRemark()).isEqualTo("消费奖励");
}
// ==================== deductPoints 测试 ====================
@Test
void deductPoints_should_return_true_and_balance_decrease_when_sufficient() {
pointsService.addPoints("user004", 500, "充值");
boolean result = pointsService.deductPoints("user004", 200, "兑换商品");
assertThat(result).isTrue();
assertThat(pointsService.getBalance("user004")).isEqualTo(300);
}
@Test
void deductPoints_should_throw_exception_when_account_not_exist() {
assertThatThrownBy(() -> pointsService.deductPoints("user_not_exist", 100, "测试"))
.isInstanceOf(AccountNotFoundException.class)
.hasMessage("用户积分账户不存在");
}
@Test
void deductPoints_should_throw_exception_when_balance_insufficient() {
pointsService.addPoints("user005", 100, "充值");
assertThatThrownBy(() -> pointsService.deductPoints("user005", 200, "兑换"))
.isInstanceOf(InsufficientPointsException.class)
.hasMessage("用户积分余额不足");
}
@Test
void deductPoints_should_success_when_balance_equal_to_deduct() {
pointsService.addPoints("user006", 100, "充值");
boolean result = pointsService.deductPoints("user006", 100, "全部扣减");
assertThat(result).isTrue();
assertThat(pointsService.getBalance("user006")).isEqualTo(0);
}
@Test
void deductPoints_should_throw_exception_when_params_invalid() {
pointsService.addPoints("user007", 100, "充值");
assertThatThrownBy(() -> pointsService.deductPoints("", 100, "测试"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> pointsService.deductPoints("user007", 0, "测试"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> pointsService.deductPoints("user007", 100, ""))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void deductPoints_should_generate_flow_record() {
pointsService.addPoints("user008", 500, "充值");
pointsService.deductPoints("user008", 200, "兑换商品");
List<PointFlow> flows = pointsService.getUserFlows("user008");
assertThat(flows).hasSize(2);
assertThat(flows.get(1).getType()).isEqualTo(PointType.DEDUCT);
assertThat(flows.get(1).getPoints()).isEqualTo(200);
}
// ==================== getBalance 测试 ====================
@Test
void getBalance_should_return_correct_balance() {
pointsService.addPoints("user009", 300, "测试");
assertThat(pointsService.getBalance("user009")).isEqualTo(300);
}
@Test
void getBalance_should_return_zero_when_account_not_exist() {
assertThat(pointsService.getBalance("user_not_exist")).isEqualTo(0);
}
@Test
void getBalance_should_throw_exception_when_userId_blank() {
assertThatThrownBy(() -> pointsService.getBalance(""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("用户ID不能为空");
}
}
这时候运行测试,全部会报错,因为PointsService还没实现,这就是TDD的红阶段。所有测试用例完全对齐SDD规范,每一条测试对应一个行为契约。
第三步:AI生成业务实现
把SDD规范和实体类定义一起丢给AI,让它生成PointsService的实现代码。注意不要让它改测试,只做实现。
下面是AI生成的基础实现版本,完全满足SDD契约,能让所有测试通过。
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class PointsService {
// 内存存储账户信息,生产环境替换为数据库
private final ConcurrentHashMap<String, PointsAccount> accountMap = new ConcurrentHashMap<>();
// 内存存储流水记录,生产环境替换为数据库
private final List<PointFlow> flowList = new ArrayList<>();
public boolean addPoints(String userId, int points, String source) {
// 参数校验
validateUserId(userId);
validatePointsRange(points);
if (source == null || source.isBlank()) {
throw new IllegalArgumentException("积分来源不能为空");
}
// 原子更新账户余额
accountMap.compute(userId, (k, oldAccount) -> {
int newBalance = (oldAccount == null ? 0 : oldAccount.getBalance()) + points;
PointsAccount account = oldAccount == null ? new PointsAccount(userId) : oldAccount;
account.setBalance(newBalance);
return account;
});
// 记录流水
synchronized (flowList) {
flowList.add(new PointFlow(userId, points, PointType.ADD, source, LocalDateTime.now()));
}
return true;
}
public boolean deductPoints(String userId, int points, String reason) {
validateUserId(userId);
validatePointsRange(points);
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("扣减原因不能为空");
}
PointsAccount account = accountMap.get(userId);
if (account == null) {
throw new AccountNotFoundException("用户积分账户不存在");
}
// 原子扣减,并发安全
accountMap.computeIfPresent(userId, (k, acc) -> {
if (acc.getBalance() < points) {
throw new InsufficientPointsException("用户积分余额不足");
}
acc.setBalance(acc.getBalance() - points);
return acc;
});
// 记录流水
synchronized (flowList) {
flowList.add(new PointFlow(userId, points, PointType.DEDUCT, reason, LocalDateTime.now()));
}
return true;
}
public Integer getBalance(String userId) {
validateUserId(userId);
PointsAccount account = accountMap.get(userId);
return account == null ? 0 : account.getBalance();
}
public List<PointFlow> getUserFlows(String userId) {
validateUserId(userId);
synchronized (flowList) {
return flowList.stream()
.filter(flow -> flow.getUserId().equals(userId))
.collect(Collectors.toList());
}
}
// 公共校验方法
private void validateUserId(String userId) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("用户ID不能为空");
}
if (userId.length() > 32) {
throw new IllegalArgumentException("用户ID长度不能超过32位");
}
}
private void validatePointsRange(int points) {
if (points <= 0) {
throw new IllegalArgumentException("积分数量必须为正整数");
}
if (points > 100000) {
throw new IllegalArgumentException("单次增加积分不能超过100000");
}
}
}
把这段代码放进去,再运行所有测试,全部通过,进入绿阶段。
这里要说明两点:第一,这是内存版的演示实现,生产环境需要用数据库事务保证余额和流水的原子性;第二,AI生成的代码能跑通测试,但结构和可读性还有优化空间,这就是重构阶段要做的事。
第四步:重构优化
绿阶段的目标是“最快让测试通过”,代码可以写得很朴素,甚至有重复。重构阶段的目标是提升代码质量,全程不改变外部行为,所有测试必须保持通过。
上面的代码有几个可以优化的点:
- 参数校验逻辑分散,重复定义了异常提示,可以统一封装常量。
- 流水记录的操作重复,可以提取公共方法。
- 扣减逻辑的并发处理还可以更严谨。
- 魔法数字可以提取成常量。
重构后的代码如下:
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class PointsService {
private static final int MAX_USER_ID_LENGTH = 32;
private static final int MAX_SINGLE_POINTS = 100000;
private static final String MSG_USER_ID_BLANK = "用户ID不能为空";
private static final String MSG_USER_ID_LENGTH = "用户ID长度不能超过32位";
private static final String MSG_POINTS_INVALID = "积分数量必须为正整数";
private static final String MSG_POINTS_EXCEED = "单次积分操作不能超过100000";
private static final String MSG_SOURCE_BLANK = "积分来源不能为空";
private static final String MSG_REASON_BLANK = "扣减原因不能为空";
private static final String MSG_ACCOUNT_NOT_FOUND = "用户积分账户不存在";
private static final String MSG_INSUFFICIENT_POINTS = "用户积分余额不足";
private final ConcurrentHashMap<String, PointsAccount> accountMap = new ConcurrentHashMap<>();
private final List<PointFlow> flowList = new ArrayList<>();
public boolean addPoints(String userId, int points, String source) {
validateUserId(userId);
validatePointsRange(points);
validateNotBlank(source, MSG_SOURCE_BLANK);
accountMap.compute(userId, (k, oldAccount) -> {
int newBalance = (oldAccount == null ? 0 : oldAccount.getBalance()) + points;
PointsAccount account = oldAccount == null ? new PointsAccount(userId) : oldAccount;
account.setBalance(newBalance);
return account;
});
recordFlow(userId, points, PointType.ADD, source);
return true;
}
public boolean deductPoints(String userId, int points, String reason) {
validateUserId(userId);
validatePointsRange(points);
validateNotBlank(reason, MSG_REASON_BLANK);
if (!accountMap.containsKey(userId)) {
throw new AccountNotFoundException(MSG_ACCOUNT_NOT_FOUND);
}
accountMap.computeIfPresent(userId, (k, acc) -> {
if (acc.getBalance() < points) {
throw new InsufficientPointsException(MSG_INSUFFICIENT_POINTS);
}
acc.setBalance(acc.getBalance() - points);
return acc;
});
recordFlow(userId, points, PointType.DEDUCT, reason);
return true;
}
public Integer getBalance(String userId) {
validateUserId(userId);
PointsAccount account = accountMap.get(userId);
return account == null ? 0 : account.getBalance();
}
public List<PointFlow> getUserFlows(String userId) {
validateUserId(userId);
synchronized (flowList) {
return flowList.stream()
.filter(flow -> flow.getUserId().equals(userId))
.toList();
}
}
private void validateUserId(String userId) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException(MSG_USER_ID_BLANK);
}
if (userId.length() > MAX_USER_ID_LENGTH) {
throw new IllegalArgumentException(MSG_USER_ID_LENGTH);
}
}
private void validatePointsRange(int points) {
if (points <= 0) {
throw new IllegalArgumentException(MSG_POINTS_INVALID);
}
if (points > MAX_SINGLE_POINTS) {
throw new IllegalArgumentException(MSG_POINTS_EXCEED);
}
}
private void validateNotBlank(String str, String errorMsg) {
if (str == null || str.isBlank()) {
throw new IllegalArgumentException(errorMsg);
}
}
private void recordFlow(String userId, int points, PointType type, String remark) {
synchronized (flowList) {
flowList.add(new PointFlow(userId, points, type, remark, LocalDateTime.now()));
}
}
}
重构完再跑一遍所有测试,全部通过。代码结构更清晰,重复逻辑被消除,常量统一管理,后续维护成本大幅降低。这就是TDD的价值——有测试兜底,你可以放心大胆地重构,不用担心改出问题。
整个流程下来,人核心做的是SDD编写和测试设计,大概20分钟;AI生成实现和初步重构,5分钟不到。如果纯手写,至少要40分钟以上,而且边界条件还容易漏。效率和质量,在这里是同时提升的。
SDD编写方法论:写出AI能精准执行的规范
SDD是整个流程的源头,写不好SDD,后面全白搭。很多人说AI生成的代码不对,先看看自己的需求描述是不是模糊不清。经过上百个项目的打磨,我总结出一套高可用的SDD编写方法,照着写,AI生成代码的一次通过率能稳定在85%以上。
SDD编写的四个核心原则
- 精确无歧义:所有描述必须是可验证的,不能出现“大概”“尽量”“左右”这种模糊词汇。比如不能说“积分不能太多”,要说“单次积分最大值为100000”。
- 边界全覆盖:正常流程只是基础,异常、边界、极限场景才是重点。AI天生倾向于只写happy path,你不提,它大概率不会主动加。
- 轻量化粒度:单个SDD对应一个方法或一个小功能,不要写几十页的大文档。粒度越细,AI理解越准,测试越好设计。
- 结构化表达:固定的结构比自由的散文效果好。用固定的模块拆分,AI能精准提取每个部分的信息,不会遗漏。
通用SDD模板
直接套用这个模板,绝大多数业务场景都能覆盖:
## 【功能标识】
**核心职责**:一句话说清功能的唯一作用
**前置条件**:执行该功能必须满足的前提
### 输入契约
| 参数名 | 类型 | 约束条件 | 说明 |
|--------|------|----------|------|
| | | | |
### 输出契约
正常返回:返回值类型与含义
异常返回:异常类型、触发条件、错误信息
### 业务规则
1. 正常执行流程
2. 数据处理规则
3. 不变式约束
### 非功能要求
- 性能:
- 安全:
- 并发:
三个提升AI准确率的实用技巧
- 加入反例:在规范里说明“什么是不允许的”,比只说“允许什么”效果更好。比如除了说“积分必须为正整数”,再加一句“不允许传入0和负数”,AI会更注意边界校验。
- 给出示例:复杂的输入输出,配上1-2个正确示例和1个错误示例,AI的理解偏差会大幅降低。尤其是格式类的约束,示例比文字描述管用10倍。
- 限定实现约束:明确告诉AI不能用什么、必须用什么。比如“必须使用ConcurrentHashMap保证线程安全,不允许使用synchronized修饰方法”,避免AI生成不符合技术规范的代码。
很多人写AI提示词,喜欢堆一大堆修饰词,其实没用。真正决定质量的,是结构化的契约定义,这就是SDD的核心。
TDD进阶:AI场景下的测试设计与重构策略
TDD不是写越多测试越好,也不是覆盖率越高越好。在AI编程的语境下,TDD的核心作用是“契约固化”和“安全垫”,要讲究策略,不然会陷入测试维护的泥潭。
测试设计的分层策略
不同层级的代码,测试的粒度和要求不一样,不能一刀切:
- 核心业务层:比如Service层的业务逻辑,必须严格执行TDD,所有业务规则、异常场景、边界条件全覆盖,覆盖率要求100%。这部分是系统的核心,也是AI最容易出逻辑bug的地方,必须用测试牢牢守住。
- 数据访问层:Mapper/Repository层,不用写太细的单元测试,重点做集成测试,验证SQL逻辑的正确性。AI生成的SQL经常有语法问题或逻辑漏洞,靠单元测试测不出来。
- Controller层:重点测参数校验和返回格式,业务逻辑交给Service层的测试覆盖,不用重复测业务。
- 工具类与配置类:核心工具方法写测试,简单的工具类、纯配置类不用追求覆盖率。
我见过很多团队为了凑覆盖率,给getter/setter写测试,给配置类写测试,纯纯的无效劳动。测试的价值和业务重要性成正比,核心业务多投入,边缘代码少投入。
AI生成测试的校验方法
AI可以帮你生成测试初稿,但绝对不能直接用,必须人工校验三个点:
- 测行为不测实现:看测试是不是只调用公共方法,验证返回结果和状态变化。如果测试里调用了私有方法、验证内部变量,直接打回重写——这种测试和实现强绑定,一重构就全挂。
- 断言有效性:很多AI会生成“恒真”断言,或者断言和输入没关系。比如测积分增加,只断言返回true,不断言余额变化,等于没测。
- 场景完整性:检查是不是漏了异常场景、边界值。AI生成的测试普遍happy path很全,异常场景漏很多,尤其是组合异常的情况。
校验测试质量有个很简单的方法:故意把实现代码改错,看测试能不能测出来。如果改错了业务逻辑,测试还能通过,那这个测试就是无效的。
重构的正确姿势
重构是TDD三阶段里最容易被跳过的一步,也是决定代码长期可维护性的一步。AI生成的代码普遍“能用但不好看”,重复代码多、命名不规范、结构混乱,不重构的话,代码量上来之后很快就会变成屎山。
AI时代的重构遵循三个原则:
- 测试先行,小步快跑:每次只改一个点,改完立刻跑测试。不要一次性大改,改出问题都不知道是哪步错的。
- 不改行为,只优结构:重构全程不能改变外部行为,不能偷偷加功能改逻辑。想加功能,先加测试,再走一轮红-绿-重构。
- AI辅助,人工把控:简单的提取方法、重命名、格式化可以交给AI做,核心的结构优化、设计模式应用必须人来把控。AI很容易过度设计,为了用设计模式而用设计模式。
重构完有两个验收标准:所有测试通过,代码可读性比之前好。满足这两点,重构就是成功的。
工具链落地:Java生态下的完整组合
工欲善其事,必先利其器。一套顺手的工具链,能让SDD+TDD的落地效率翻倍。
核心工具选型
- AI编程助手
- 日常开发推荐Cursor编辑器,支持全项目上下文,生成代码时能自动读取现有类和规范,一致性更好。
- IDEA用户可以用官方的AI Assistant或者豆包编程助手插件,不用切换编辑器,和Java生态适配更好。
- 核心原则:AI只做实现辅助,SDD和测试设计必须人主导。
- 测试框架
- 单元测试:JUnit 5,目前Java生态的标准,功能全面,适配性好。
- 断言库:AssertJ,流式断言,语义清晰,可读性比JUnit原生断言高很多。
- Mock工具:Mockito,用于隔离外部依赖,单元测试只测当前层逻辑。
- 架构测试:ArchUnit,用来定义代码架构规范,比如Service层不能直接访问Controller层,AI生成的代码很容易乱调依赖,用这个可以自动校验。
- 构建与CI
- 构建工具:Maven/Gradle,配置自动化测试,每次构建自动跑单元测试。
- CI流水线:Jenkins/GitHub Actions,代码提交后自动跑全量测试,生成覆盖率报告。测试不通过的代码,直接阻断合并。
- 规范管理
- 单个功能的SDD直接写在类注释里,和代码放在一起,避免文档和代码脱节。
- 全局的业务规则,可以放到单独的规范文档里,作为所有AI生成代码的统一约束。
工具链使用流程
- 需求拆解后,先在对应类的注释里写好SDD规范。
- 选中SDD,调用AI生成测试类初稿,人工补充完善。
- 运行测试,确认全部失败。
- 调用AI基于SDD生成业务实现。
- 运行测试,不通过就把错误信息丢给AI修复,直到全部通过。
- 人工重构优化,回归测试通过后提交。
- CI流水线自动跑全量测试,验证无误后合并。
这套流程跑顺了,单人开发效率能提升30%以上,代码质量还能上一个台阶。
避坑指南:九个常见误区与实战经验
这套方法看着简单,实际落地的时候很容易走偏。我见过很多团队推行TDD和AI编程,最后都做成了形式主义。下面这九个坑,是我踩过或者见过粉丝踩过的,避开它们,落地成功率至少提升一倍。
1. 把SDD写成需求文档
很多人写SDD,一写就是十几页,全是业务背景、流程说明,就是没有精确的契约。SDD不是给产品看的需求文档,是给AI和开发看的行为契约,要精确、简短、可验证。单个方法的SDD控制在300字以内,多了就是冗余。
2. 测试与实现强绑定
这是新手最容易犯的错。测试代码里到处是反射调用私有方法、验证内部变量、Mock底层实现。这种测试写的时候费劲,维护的时候更费劲——改一行实现代码,十个测试跟着挂。正确的做法是:只测公共接口的外部行为,不管内部怎么实现。
3. 追求100%测试覆盖率
覆盖率是参考指标,不是目标。为了凑覆盖率,给简单的getter/setter、枚举类、异常类写测试,纯纯的浪费时间。核心业务逻辑覆盖率要100%,边缘代码覆盖率低一点没关系。比覆盖率更重要的,是业务场景的覆盖率——所有业务规则、异常场景都测到了,比凑数字有用得多。
4. 完全依赖AI生成测试
AI生成的测试,看起来有模有样,实则很多无效用例。要么断言没意义,要么漏了关键场景,甚至有测试和实现逻辑同错的情况。测试是质量的最后一道防线,这道防线必须人来守。AI只能做初稿,人工审核是底线。
5. 跳过重构环节
很多人觉得“测试都过了,能用就行”,跳过重构。短期看省了时间,长期看代码会越来越乱,AI生成的代码风格本来就不统一,不做规整,几个迭代下来就没人能维护了。重构花的5分钟,能省后续几个小时的维护成本。
6. 所有代码都用这套范式
不是所有代码都值得严格走SDD+TDD。比如写个临时脚本、做个快速原型、验证个技术方案,怎么快怎么来,不用拘泥于流程。只有长期维护的核心业务代码、团队协作的公共模块,才值得投入时间做规范和测试。
7. SDD和代码不同步
需求变了,只改代码不改SDD,时间长了SDD就成了过时的废纸。SDD和代码是绑定的,改代码先改SDD,再改测试,最后改实现。SDD永远是最新的行为契约,这样后面的人看代码,先看SDD就知道这个方法是干嘛的。
8. 过度设计SDD
一开始就想把所有细节、所有扩展都考虑到,SDD写得无比复杂,结果业务一变,全白写了。SDD要渐进式完善,先覆盖核心场景,后续迭代慢慢补充。够用就好,过度设计也是浪费。
9. 把AI当成主角
很多人用这套方法,变成了AI写SDD、AI写测试、AI写代码,人只负责点运行。这样做短期很爽,长期一定会出问题。人是质量的最终责任人,AI是辅助工具。核心的需求理解、规则制定、质量把控,必须牢牢抓在人手里。
团队落地:从小范围试点到全员推广
个人用好这套方法不难,难的是在团队里落地推广。我见过很多团队强行推TDD,最后怨声载道,不了了之。团队落地要讲究节奏,不能上来就全员全量。
第一步:选对试点范围
先从一个小团队、一个核心模块开始试点,不要上来就全公司推广。选业务稳定、需求清晰、长期维护的模块,比如用户中心、支付模块,这些模块质量要求高,测试价值大,容易出成果。
试点阶段不要定KPI,不要强制覆盖率,重点是跑通流程,积累经验,产出可复制的模板和规范。
第二步:统一规范模板
试点跑通后,沉淀出团队统一的SDD模板、测试编写规范、代码风格要求。统一的标准很重要,不然每个人写的SDD格式不一样,AI生成的代码风格千差万别,协作成本反而更高。
模板不用太复杂,就用前面给的通用结构,团队可以根据自己的业务做微调。关键是所有人都用同一套格式。
第三步:融入Code Review流程
把SDD和测试纳入Code Review的必查项。CR的时候先看SDD写得清不清楚,再看测试用例覆盖全不全,最后才看实现代码。实现是AI写的,一般不会有语法问题,重点看需求有没有理解对,测试有没有到位。
如果CR的时候发现SDD写得模糊,直接打回重写,不要往下走。源头歪了,后面全是错的。
第四步:建设自动化校验
把测试、覆盖率、架构规则都接入CI流水线,自动化校验,不通过就不能合并。用机器管规则,比人管效率高得多,也不容易有矛盾。
比如规定核心模块单元测试覆盖率必须达到80%,ArchUnit架构校验必须通过,这些都让流水线自动检查,不用CR的时候人工查。
第五步:逐步扩大范围
试点模块跑顺了,有了明确的效率和质量提升数据,再慢慢推广到其他团队、其他模块。用实际效果说话,比行政命令管用得多。大家看到用这套方法bug少了、加班少了,自然愿意用。
终局思考:开发者的核心能力正在迁移
AI编程发展到今天,已经没人会质疑它的效率。但很多开发者的焦虑也越来越重——AI写代码越来越快,越来越准,程序员会不会被取代?
我的答案是:只会写代码的程序员,一定会被取代;但能定义问题、制定规则、把控质量的开发者,会越来越值钱。
SDD+TDD这套范式,本质上是把开发者的工作,从“实现层”提升到了“契约层”。以前你要自己想、自己写、自己测;现在你定义清楚规则,AI帮你实现,测试帮你验证。你的核心价值,不再是敲了多少行代码,而是能不能把模糊的业务需求,转化成精确、可执行、可验证的行为契约。
这不是倒退,是升级。软件开发从来不是为了写代码,是为了解决业务问题。代码只是实现需求的载体,当AI能承担大部分载体工作的时候,人就应该回归到问题本身——理解业务、定义规则、设计系统、把控质量。
未来的编程,不会是对着编辑器一行行敲代码,而是对着AI,用精确的规范描述你想要的行为,用完善的测试验证结果,用系统化的设计保证系统的可维护性。SDD和TDD,就是这个时代的基础能力。
越早完成这个能力升级,你就越难被替代。