写测试与调 bug 的工程流:用例有仇家,failing 敢红,先定位再修复
前面四讲都是”怎么说话”,这一讲开始”怎么干活”:让它写测试、调 bug——程序员每天最真实的两件事。
先说一个可能让你不太舒服的事实:让 AI 写测试,最危险的不是它漏测,是它给你一套全绿的测试,你一看全绿,就放心了。为什么?往下看实测。
问题一:全绿的测试,就健康吗?
我拿那个带三个坑的 settle() 方法(double 算钱、异常被吞、total 可为负),让它写单元测试。
朴素问法下,它给了 4 个用例,覆盖面还不赖:正常两个、空商品一个、扣款异常一个。但你看它怎么写断言的:
空商品用例:`verify(paymentService).charge(“user-3”, 0.0)`——把”0 元也要扣款”当成该有的行为来验证。
扣款异常用例:`assertEquals(40.0, total)` + `verify(log).info(“settle failed”)`——异常被吞、金额照常返回,被它当成正确语义写进断言。
这套测试跑起来,全绿。全绿的代价是:bug 被冻结成了规格。以后谁修复”异常被吞”,这个测试反而变红,逼得修复者回来改测试。
不暴露问题的测试是安慰剂;把 bug 测成特性的测试,是安眠药。
问题二:怎么让测试有”仇家”?
工程化提示词里,我加了两条:
① 每个用例用一行注释说明:它在防什么 bug——写不出”在防什么”的用例不要;
② 你发现的代码缺陷,不要改代码,用测试把它暴露出来(failing test 也是成果)。
它的输出立刻变了:6 个用例,每个带”防:……”注释——防总价算错、防 0 元无意义扣款、防券超额为负、防异常被静默吞。最后附一张「预期失败的测试及其暴露的缺陷」表,四个 failing test,每个对着一个真缺陷。
第 ① 条把测试从形式主义拉回目的论:每个用例都要有仇家。数量是虚荣指标,防住什么 bug 才是价值。
第 ② 条是认知翻转:AI 倾向于让测试全绿(讨好),而好测试应该敢红。它面对缺陷的态度,从”证明代码对”变成了”帮助代码变好”。
展开看实测原文(朴素 vs 工程化,沈剑2026-09-25实测:deepseek-flash,可复现,均为独立session以排除上下文影响)
实验材料(同一份材料喂给两条提示词)
```java
public double settle(Order order) {
double total = 0;
for (Item item : order.getItems()) {
total += item.getPrice() * item.getCount();
}
if (order.getCoupon() != null) {
total = total - order.getCoupon().getAmount();
}
try {
paymentService.charge(order.getUserId(), total);
} catch (Exception e) {
log.info("settle failed");
}
return total;
}
```
朴素提示词
给这个方法写单元测试(Java,JUnit 5)。
```java
public double settle(Order order) {
double total = 0;
for (Item item : order.getItems()) {
total += item.getPrice() * item.getCount();
}
if (order.getCoupon() != null) {
total = total - order.getCoupon().getAmount();
}
try {
paymentService.charge(order.getUserId(), total);
} catch (Exception e) {
log.info("settle failed");
}
return total;
}
```
朴素输出(4 个用例,缺陷被写成预期行为)
下面是一个基于 **JUnit 5 + Mockito** 的单元测试示例。
假设被测类名为 `SettleService`,其中 `paymentService`、`log` 是可注入字段;`Order.userId` 为 `String`,`Item.getPrice()` 返回 `double`,`Item.getCount()` 返回 `int`,`Coupon.getAmount()` 返回 `double`。
```java
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.Logger;
@ExtendWith(MockitoExtension.class)
class SettleServiceTest {
@Mock
private PaymentService paymentService;
@Mock
private Logger log;
@InjectMocks
private SettleService settleService;
@Test
void settle_shouldCalculateTotalAndCharge_whenNoCoupon() {
Item item1 = mock(Item.class);
when(item1.getPrice()).thenReturn(10.0);
when(item1.getCount()).thenReturn(2);
Item item2 = mock(Item.class);
when(item2.getPrice()).thenReturn(5.5);
when(item2.getCount()).thenReturn(3);
Order order = mock(Order.class);
when(order.getItems()).thenReturn(List.of(item1, item2));
when(order.getCoupon()).thenReturn(null);
when(order.getUserId()).thenReturn("user-1");
double total = settleService.settle(order);
assertEquals(36.5, total, 0.0001);
verify(paymentService).charge("user-1", 36.5);
verifyNoInteractions(log);
}
@Test
void settle_shouldSubtractCoupon_whenCouponExists() {
Item item = mock(Item.class);
when(item.getPrice()).thenReturn(100.0);
when(item.getCount()).thenReturn(1);
Coupon coupon = mock(Coupon.class);
when(coupon.getAmount()).thenReturn(30.0);
Order order = mock(Order.class);
when(order.getItems()).thenReturn(List.of(item));
when(order.getCoupon()).thenReturn(coupon);
when(order.getUserId()).thenReturn("user-2");
double total = settleService.settle(order);
assertEquals(70.0, total, 0.0001);
verify(paymentService).charge("user-2", 70.0);
verifyNoInteractions(log);
}
@Test
void settle_shouldChargeZero_whenOrderHasNoItemsAndNoCoupon() {
Order order = mock(Order.class);
when(order.getItems()).thenReturn(List.of());
when(order.getCoupon()).thenReturn(null);
when(order.getUserId()).thenReturn("user-3");
double total = settleService.settle(order);
assertEquals(0.0, total, 0.0001);
verify(paymentService).charge("user-3", 0.0);
verifyNoInteractions(log);
}
@Test
void settle_shouldReturnTotalAndLog_whenPaymentServiceThrows() {
Item item = mock(Item.class);
when(item.getPrice()).thenReturn(20.0);
when(item.getCount()).thenReturn(2);
Order order = mock(Order.class);
when(order.getItems()).thenReturn(List.of(item));
when(order.getCoupon()).thenReturn(null);
when(order.getUserId()).thenReturn("user-4");
doThrow(new RuntimeException("payment failed"))
.when(paymentService).charge("user-4", 40.0);
double total = settleService.settle(order);
assertEquals(40.0, total, 0.0001);
verify(paymentService).charge("user-4", 40.0);
verify(log).info("settle failed");
}
}
```
如果实际代码中的 `log` 是 `private static final`,无法通过 `@Mock` 注入,那么可以去掉 `Logger log` 相关的 mock 和 `verify(log).info(...)`,只验证金额计算与 `paymentService.charge` 调用即可。
工程化提示词
给这个方法写单元测试(Java,JUnit 5)。
要求:
1. 必须覆盖:正常路径、边界(空商品列表、优惠券金额超过商品总额、金额为 0)、异常(扣款服务抛异常);
2. 每个用例用一行注释说明:它在防什么 bug——写不出"在防什么"的用例不要;
3. 你发现的代码缺陷,不要改代码,用测试把它暴露出来( failing test 也是成果)。
```java
public double settle(Order order) {
double total = 0;
for (Item item : order.getItems()) {
total += item.getPrice() * item.getCount();
}
if (order.getCoupon() != null) {
total = total - order.getCoupon().getAmount();
}
try {
paymentService.charge(order.getUserId(), total);
} catch (Exception e) {
log.info("settle failed");
}
return total;
}
```
工程化输出(6 个用例带仇家+预期失败缺陷表)
以下是为 `settle` 方法编写的 JUnit 5 单元测试。测试假设 `settle` 位于 `OrderService` 类中,且该类通过构造函数注入 `PaymentService`。为了自包含,测试代码中包含了所需的简单模型类和接口。每个测试用例都用一行中文注释说明了它在防什么 bug。其中部分测试为 **failing test**,用于暴露当前代码的缺陷。
```java
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PaymentService paymentService;
private OrderService orderService;
@BeforeEach
void setUp() {
orderService = new OrderService(paymentService);
}
// ---------- 辅助方法 ----------
private Order createOrder(List<Item> items, Coupon coupon, String userId) {
Order order = new Order();
order.setItems(items);
order.setCoupon(coupon);
order.setUserId(userId);
return order;
}
private Item createItem(double price, int count) {
Item item = new Item();
item.setPrice(price);
item.setCount(count);
return item;
}
private Coupon createCoupon(double amount) {
Coupon coupon = new Coupon();
coupon.setAmount(amount);
return coupon;
}
// ---------- 测试用例 ----------
@Test
// 防:商品总价计算错误(漏算、乘错、累加错误)
void settle_normalPath_withoutCoupon() {
Item item1 = createItem(10.0, 2); // 20.0
Item item2 = createItem(5.0, 3); // 15.0
Order order = createOrder(List.of(item1, item2), null, "user1");
double total = orderService.settle(order);
assertEquals(35.0, total, 0.001);
verify(paymentService).charge("user1", 35.0);
}
@Test
// 防:优惠券金额未正确抵扣或抵扣计算错误
void settle_normalPath_withCoupon() {
Item item = createItem(100.0, 1);
Coupon coupon = createCoupon(20.0);
Order order = createOrder(List.of(item), coupon, "user1");
double total = orderService.settle(order);
assertEquals(80.0, total, 0.001);
verify(paymentService).charge("user1", 80.0);
}
@Test
// 防:空订单仍然发起 0 元扣款请求(无意义且浪费资源)
void settle_emptyItems_shouldNotCharge() {
Order order = createOrder(Collections.emptyList(), null, "user1");
double total = orderService.settle(order);
assertEquals(0.0, total, 0.001);
verify(paymentService, never()).charge(anyString(), anyDouble());
}
@Test
// 防:优惠券超额导致总价为负,进而可能扣负数(退款)或引发下游错误
void settle_couponExceedsTotal_shouldNotReturnNegative() {
Item item = createItem(50.0, 1);
Coupon coupon = createCoupon(80.0);
Order order = createOrder(List.of(item), coupon, "user1");
double total = orderService.settle(order);
assertTrue(total >= 0, "总价不应为负数,实际为: " + total);
// 当前实现会调用 charge("user1", -30.0),这也是缺陷
// verify(paymentService).charge("user1", 0.0); // 期望修正后为0
}
@Test
// 防:0 元订单发起无意义的扣款请求
void settle_zeroAmount_shouldNotCharge() {
Item item = createItem(0.0, 10);
Order order = createOrder(List.of(item), null, "user1");
double total = orderService.settle(order);
assertEquals(0.0, total, 0.001);
verify(paymentService, never()).charge(anyString(), anyDouble());
}
@Test
// 防:扣款服务异常被静默吞掉,导致订单显示结算成功但实际未扣款
void settle_chargeThrowsException_shouldPropagate() {
Item item = createItem(100.0, 1);
Order order = createOrder(List.of(item), null, "user1");
doThrow(new RuntimeException("扣款失败")).when(paymentService).charge("user1", 100.0);
assertThrows(RuntimeException.class, () -> orderService.settle(order));
}
// ========== 以下为测试所需的简单模型类和接口(假设已存在) ==========
static class Item {
private double price;
private int count;
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }
public int getCount() { return count; }
public void setCount(int count) { this.count = count; }
}
static class Coupon {
private double amount;
public double getAmount() { return amount; }
public void setAmount(double amount) { this.amount = amount; }
}
static class Order {
private List<Item> items;
private Coupon coupon;
private String userId;
public List<Item> getItems() { return items; }
public void setItems(List<Item> items) { this.items = items; }
public Coupon getCoupon() { return coupon; }
public void setCoupon(Coupon coupon) { this.coupon = coupon; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
}
interface PaymentService {
void charge(String userId, double amount);
}
static class OrderService {
private final PaymentService paymentService;
private final Logger log = LoggerFactory.getLogger(OrderService.class);
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public double settle(Order order) {
double total = 0;
for (Item item : order.getItems()) {
total += item.getPrice() * item.getCount();
}
if (order.getCoupon() != null) {
total = total - order.getCoupon().getAmount();
}
try {
paymentService.charge(order.getUserId(), total);
} catch (Exception e) {
log.info("settle failed");
}
return total;
}
}
}
```
### 预期失败的测试及其暴露的缺陷
| 测试方法 | 预期结果 | 暴露的缺陷 |
|---------|---------|-----------|
| `settle_emptyItems_shouldNotCharge` | **失败** | 空商品列表时,`total` 为 0,代码仍会调用 `paymentService.charge(userId, 0.0)`,发起无意义的 0 元扣款请求。 |
| `settle_couponExceedsTotal_shouldNotReturnNegative` | **失败** | 优惠券金额超过商品总额时,`total` 变为负数(如 -30.0),方法返回负数并调用 `charge` 传入负数金额,可能导致下游系统退款或报错。 |
| `settle_zeroAmount_shouldNotCharge` | **失败** | 当订单金额为 0 时,代码仍会调用 `paymentService.charge(userId, 0.0)`,发起无意义的扣款请求。 |
| `settle_chargeThrowsException_shouldPropagate` | **失败** | `paymentService.charge` 抛出异常时,代码仅记录 `log.info("settle failed")` 并吞掉异常,方法仍返回 `total`,调用方无法感知扣款失败,可能误以为结算成功。 |
正常路径的两个测试(`settle_normalPath_withoutCoupon` 和 `settle_normalPath_withCoupon`)预期通过,用于验证核心计算逻辑的正确性。
问题三:调 bug,为什么别让它上来就改代码?
第 4 讲的调试案例(点开看实测原文)里还有个细节值得单独说:我特意要求”先定位最可能的原因,再给验证方法,最后才给修复建议。别急着改代码。“
它给的验证方法非常工程化:先加临时日志打印 `items is null?`(不断言、不改业务逻辑),复现请求确认;或者写一个最小单测,`order.setItems(null)` 复现同样异常再确认堆栈。确认完了,才谈修。
这就是咱们调 bug 的基本功:先定位 → 再验证 → 后修复。上来就改代码,是开盲盒——它改的可能根本不是病根,你还以为修好了。
带实习生的时候也这么说:先复现,别乱改。AI 这个实习生,一个毛病。
问题四:看它的产出,先看哪里?
以后你评审 AI 写的测试,别数用例个数,先看断言:断言里藏着它对”正确”的理解。
它写 `assertEquals(40.0, total)`,你就要问一句:扣款失败了还返回 40,这真的”正确”吗?它写 `verify(charge(0.0))`,你就要问:0 元还发起扣款,合理吗?
用例是它给的,判断是你的。它越能干,你的评审越要往”它把什么当成了正常”这个点上打。
实操演练
必做部分 + 一步加餐。准备一个你日常在用的 AI 助手和一段带坑的代码。
检验一下
①你的测试与调试提示词 + 仇家清单:把一次真实的测试/调试过程沉淀成提示词(含”每个用例说明防什么”+”failing test 也是成果”+”先定位再验证后修复”)。实测后记录:它帮你暴露了哪个你原来没注意的坑?
这是资产库的第五格。
本讲实测来自沈剑的案例库 005、007(deepseek-flash,2026-09-25~09-30,独立会话,复现脚本和原始输出存档)。
(沈剑注:全绿测试我最怕。这些年review别人的测试,我练出来的习惯就是不看数量看断言——断言写的是”它以为的正常”,里面全是坑。)
但还有一个没解决的问题:招式都会了,那网上那些”经典框架”——few-shot、”让我们一步一步思考”——还要不要信?什么时候是真有用,什么时候是花架子?——第 6 讲,我们实测给你看。
知其然,知其所以然。思路比结论更重要。
Anthropic 官方博客:Prompt engineering best practices——迭代式提示与「技术选择决策框架」,正是本讲工程流思维的官方表达。