提示词工程:怎么把 AI 当同事用?(5/7)

写测试与调 bug 的工程流:用例有仇家,failing 敢红,先定位再修复

提示词工程第 5 讲封面:怎么把它当同事用?

写测试与调 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 助手和一段带坑的代码。

检查它写的断言:是”证明对”还是”暴露错”
找一段你熟悉的带坑代码(或直接用本讲的 settle()),让它写单元测试。逐条看断言:有没有把缺陷当成正常行为写进去?加上”每个用例说明防什么 bug + failing test 也是成果”再写一次,对比。
第一次它可能把坑写成规格;第二次它应该开始给 failing test,并主动列出”这些测试暴露了哪些缺陷”
(加餐·选做)让它按”先定位再验证后修复”调一个真 bug
拿一个你最近的报错,要求它”先定位原因(指到行),给验证方法,确认后再给修复建议,别急着改代码”。看它给的是修复代码,还是验证手册。
它应该先给复现/验证步骤(加日志、最小复现),而不是一上来就甩一段”修好的”代码

检验一下

检索练习 · 全绿陷阱
AI 写的测试全部通过,最应该警惕的是?

检索练习 · 用例的仇家
“每个用例必须说明在防什么 bug”这条要求,本质是在防什么?

检索练习 · failing test
为什么说”failing test 也是成果”?

检索练习 · 调试顺序
“先定位→再验证→后修复”这个顺序,防的是什么?

课后作业一份产出,自己留存(第 6 讲直接用):

①你的测试与调试提示词 + 仇家清单:把一次真实的测试/调试过程沉淀成提示词(含”每个用例说明防什么”+”failing test 也是成果”+”先定位再验证后修复”)。实测后记录:它帮你暴露了哪个你原来没注意的坑?

这是资产库的第五格。

作业实现思路你刚写的测试提示词,在我的案例库里是案例 005:6 个用例每个带”防:……”注释,最后一张”预期失败的测试及其暴露的缺陷”表。先自己动手做,再对照着看。特别提醒看它的断言——朴素版把”0 元扣款””异常被吞”都写成了预期行为,你的 AI 会不会也这样?案例库完整内容,后续我会分享出来。
本讲一手来源

本讲实测来自沈剑的案例库 005、007(deepseek-flash,2026-09-25~09-30,独立会话,复现脚本和原始输出存档)。

(沈剑注:全绿测试我最怕。这些年review别人的测试,我练出来的习惯就是不看数量看断言——断言写的是”它以为的正常”,里面全是坑。)

但还有一个没解决的问题:招式都会了,那网上那些”经典框架”——few-shot、”让我们一步一步思考”——还要不要信?什么时候是真有用,什么时候是花架子?——第 6 讲,我们实测给你看。

知其然,知其所以然。思路比结论更重要。

延伸阅读

Anthropic 官方博客:Prompt engineering best practices——迭代式提示与「技术选择决策框架」,正是本讲工程流思维的官方表达。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

京ICP备2025137728号-3