前置知识: 软件工程与测试

TDD 与 BDD

5 min中级

TDD 红-绿-重构循环与三定律实战、测试清单驱动设计、BDD 的 Given-When-Then 与 Gherkin、Cucumber 落地方式与常见误用。

1. TDD:测试驱动开发

1.1 核心思想:先写测试,再写实现

传统流程是「先实现、后补测试」,测试往往沦为形式;TDD(Test-Driven Development)把顺序倒过来:在写任何实现代码之前,先写一个会失败的测试。 测试在这里不只是验证工具,更是设计工具——为了写测试,你必须先想清楚 这个函数的名字、参数、返回值和边界,实现只是让测试变绿的桥。

类比:TDD 像导航软件设定目的地再出发——你先明确「到达时的样子」(断言), 再决定走哪条路(实现);走偏了立刻有提示(测试变红)。

前置知识:单元测试结构(AAA 模式)、至少一门语言的测试框架。

1.2 红-绿-重构循环

flowchart LR
    R["红 Red<br/>写一个失败的小测试"] --> G["绿 Green<br/>用最简实现让它通过"]
    G --> B["蓝 Blue<br/>重构:消除重复、改善命名"]
    B --> R

每轮循环通常只有几分钟;循环多轮后统一跑一次全量测试与提交。

1.3 TDD 三定律(Robert C. Martin 表述)

  1. 不写任何实现代码,除非为了让一个失败的测试通过;
  2. 只写刚好让测试失败的测试(一次一个断言场景);
  3. 只写刚好让测试通过的实现。

三条定律合起来强制你以「小步幅」前进,每次失败都发生在刚刚改过的几行里, 调试成本被压到最低。

1.4 完整示例:订单折扣(TypeScript + Vitest)

需求:满 100 减 20,满 500 减 150,不含并列优惠。

第一轮(红):先写测试,此时 discount 还不存在,运行即红。

import { describe, it, expect } from 'vitest';
import { discount } from '../src/discount';

describe('订单折扣', () => {
  it('订单 120 元应减 20 元', () => {
    expect(discount(120)).toBe(20);   // 失败:discount 未实现
  });
});

第二轮(绿):用最简实现让它通过,哪怕「作弊」。

export function discount(amount: number): number {
  return 20; // 够土,但它让测试变绿——下一轮再加约束
}

第三轮(红):加第二条规则,逼实现进化。

it('订单 80 元无折扣', () => {
  expect(discount(80)).toBe(0);     // 失败:硬编码的 20 被揭穿
});

it('订单 600 元应减 150 元', () => {
  expect(discount(600)).toBe(150);  // 失败:还没有满 500 规则
});

第四轮(绿 + 重构):正式实现并整理。

export function discount(amount: number): number {
  if (amount >= 500) return 150;    // 条件从严到宽排列,便于阅读
  if (amount >= 100) return 20;
  return 0;
}

继续补边界用例(500、100、99.99),用「边界值分析」补齐清单。

1.5 测试清单与 TDD 的适用边界

动手前先列「测试清单」(如:正常满减、无折扣、恰好压线、金额为 0、负数 金额、非数字),每完成一轮划掉一项。清单保证边界不遗漏,循环保证节奏。

适合 TDD不太适合
纯逻辑:算法、规则引擎、解析器一次性脚本、UI 视觉细节
接口/服务层,依赖可用替身隔离探索性原型(需求本身还在变)
缺陷修复(先写复现缺陷的测试再修)胶水代码、配置类改动

常见误用:一次写十几个测试再实现(违背小步幅);对私有方法逐个 TDD (应测公开行为);断言写得太碎或太弱(测试失去了保护作用)。

2. BDD:行为驱动开发

2.1 从「测试」到「行为」

TDD 解决了开发者的设计节奏问题,但测试代码只有开发者看得懂。BDD(Behavior- Driven Development)用业务各方都能读的自然语言场景描述行为,把 TDD 的 循环外推到需求讨论环节:产品、测试、开发共同确认场景,场景即验收标准, 再由工具把场景「粘」到自动化代码上。

BDD 最持久的遗产不是工具,而是 Given-When-Then 三段式——不写 Cucumber 也值得在测试命名与注释中沿用这套结构。

2.2 Given-When-Then

  • Given(给定):前置状态——世界现在是什么样;
  • When(当):触发动作——发生了什么操作或事件;
  • Then(那么):可观察结果——系统应该变成什么样。

2.3 Gherkin 与 Cucumber

Gherkin 是 BDD 场景的结构化自然语言(支持中文关键词 假如/当/那么), Cucumber 是执行它的代表工具(另有 Python 的 behave、.NET 的 Reqnroll 等 对应物)。

# features/discount.feature
功能: 订单满减折扣

  场景: 满 500 元享受大额优惠
    假如 购物车中有 600 元的商品
    当 用户提交订单
    那么 系统应减免 150 元
    并且 订单应付 450 元
// steps/discount.steps.ts —— 把每句自然语言粘到代码
import { Given, When, Then } from '@cucumber/cucumber';
import { strict as assert } from 'node:assert';
import { discount } from '../../src/discount';

let cartAmount: number;
let saved: number;

Given('购物车中有 {int} 元的商品', (amount: number) => {
  cartAmount = amount;
});

When('用户提交订单', () => {
  saved = discount(cartAmount);
});

Then('系统应减免 {int} 元', (expected: number) => {
  assert.equal(saved, expected);
});

2.4 落地建议与常见误用

  • 场景应表述业务规则而非页面操作。「当用户提交订单」是规则, 「当用户点击 id=submit 的按钮」是把 UI 细节泄漏进需求。
  • 场景数量克制:每个功能 5-10 个关键场景,覆盖主要分支与失败路径; 试图用 Gherkin 描述一切会让维护成本失控。
  • 反模式「Cucumber 浮肿」:一层 Gherkin + 一层胶水代码 + 一层页面对象, 三层转发只为测一个函数。简单逻辑直接 TDD 即可,BDD 只用在需要业务方 确认的验收场景。

3. TDD 与 BDD 的关系

维度TDDBDD
驱动者开发者产品/测试/开发三方
语言代码Gherkin 等结构化自然语言
关注点单元行为与设计业务行为与验收标准
产物单元测试套件可执行的验收场景
循环节奏红-绿-重构(分钟级)讨论澄清-场景化-自动化

两者不冲突:常见组合是「外层少量 BDD 验收场景 + 内层大量 TDD 单元循环」, 对应测试金字塔「少 E2E、多单元」的形状。

小结

  • 初学者要点:TDD 三步——先写失败的小测试、最简实现转绿、重构;动手前列 测试清单防漏边界;Given-When-Then 是描述任何测试的通用语言。
  • 进阶注意:TDD 的价值在「以测试逼出可测的设计」,不是覆盖率指标;缺陷 修复先用测试复现再修,防止回归;BDD 场景写业务规则不写 UI 操作,工具 只是胶水,三方共同确认场景才是 BDD 的本体。