TDD / BDD 实践
概述
TDD(Test-Driven Development,测试驱动开发)和 BDD(Behavior-Driven Development,行为驱动开发)是两种重要的敏捷软件开发方法论。TDD 强调先写测试再写实现,通过"红-绿-重构"循环驱动代码设计;BDD 则从用户行为和业务需求角度描述系统行为,使技术团队和非技术人员能够共享对系统的理解。在 PHP 生态中,PHPUnit 是 TDD 的核心工具,而 Behat 和 Codeception 则是 BDD 的主要框架。
前置知识
阅读本节前,建议先了解:PHPUnit 单元测试、Mock 对象 和 集成测试。
基础概念
TDD 核心理念
TDD 由 Kent Beck 提出,核心循环是 Red-Green-Refactor:
- Red(红):编写一个会失败的测试(定义需求)
- Green(绿):编写最少量的代码使测试通过
- Refactor(重构):优化代码结构,同时确保测试仍然通过
循环流程:
编写失败测试 → 编写实现代码 → 重构 → 编写下一个失败测试
↑ │
└──────────────────────────────────────────┘BDD 核心理念
BDD 由 Dan North 提出,使用自然语言描述系统行为:
Given(前置条件)→ When(操作)→ Then(预期结果)BDD 使非技术人员能够阅读和理解测试用例。
详细说明
TDD 实践
Red-Green-Refactor 循环示例
php
<?php
declare(strict_types=1);
namespace App\Services;
/**
* 步骤1:先写测试(RED)
* 此时 PriceCalculator 类还不存在,测试会失败
*/
class PriceCalculatorTest extends \PHPUnit\Framework\TestCase
{
public function testCalculatePriceWithNoDiscount(): void
{
$calculator = new PriceCalculator();
$price = $calculator->calculate(100.0, []);
$this->assertSame(100.0, $price);
}
}
// 运行测试 → 失败(RED): Class "App\Services\PriceCalculator" not foundphp
<?php
declare(strict_types=1);
namespace App\Services;
/**
* 步骤2:编写最少代码使测试通过(GREEN)
*/
class PriceCalculator
{
public function calculate(float $basePrice, array $discounts): float
{
return $basePrice;
}
}
// 运行测试 → 通过(GREEN)php
<?php
declare(strict_types=1);
namespace App\Services;
/**
* 步骤3:添加下一个测试(RED)
*/
class PriceCalculatorTest extends \PHPUnit\Framework\TestCase
{
// ... 前面的测试
public function testCalculatePriceWithSingleDiscount(): void
{
$calculator = new PriceCalculator();
$price = $calculator->calculate(100.0, [
['type' => 'percentage', 'value' => 10],
]);
$this->assertSame(90.0, $price);
}
}
// 运行测试 → 新测试失败(RED)php
<?php
declare(strict_types=1);
namespace App\Services;
/**
* 步骤4:扩展实现(GREEN)
*/
class PriceCalculator
{
public function calculate(float $basePrice, array $discounts): float
{
$price = $basePrice;
foreach ($discounts as $discount) {
if ($discount['type'] === 'percentage') {
$price -= $price * ($discount['value'] / 100);
}
}
return $price;
}
}
// 步骤5:重构(REFACTOR)
// - 提取折扣计算为独立方法
// - 添加类型安全
// - 添加边界检查重构阶段
php
<?php
declare(strict_types=1);
namespace App\Services;
/**
* 重构后的 PriceCalculator
* 更好的代码结构、类型安全、错误处理
*/
class PriceCalculator
{
/**
* 计算最终价格
*/
public function calculate(float $basePrice, array $discounts): float
{
$this->validatePrice($basePrice);
$price = $basePrice;
$price = $this->applyDiscounts($price, $discounts);
return $this->ensureMinimumPrice($price);
}
/**
* 应用折扣
*/
private function applyDiscounts(float $price, array $discounts): float
{
foreach ($discounts as $discount) {
$price = $this->applySingleDiscount($price, $discount);
}
return $price;
}
/**
* 应用单个折扣
*/
private function applySingleDiscount(float $price, array $discount): float
{
$type = $discount['type'] ?? '';
$value = $discount['value'] ?? 0;
return match ($type) {
'percentage' => $price * (1 - ($value / 100)),
'fixed' => max(0.0, $price - $value),
default => $price,
};
}
/**
* 确保最低价格
*/
private function ensureMinimumPrice(float $price): float
{
return max(0.01, $price);
}
private function validatePrice(float $price): void
{
if ($price < 0) {
throw new \InvalidArgumentException('价格不能为负数');
}
}
}完整的 TDD 测试套件
php
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator();
}
// === 基本功能 ===
public function testNoDiscountsReturnsBasePrice(): void
{
$this->assertSame(100.0, $this->calculator->calculate(100.0, []));
}
public function testSinglePercentageDiscount(): void
{
$this->assertSame(90.0, $this->calculator->calculate(100.0, [
['type' => 'percentage', 'value' => 10],
]));
}
public function testFixedDiscount(): void
{
$this->assertSame(80.0, $this->calculator->calculate(100.0, [
['type' => 'fixed', 'value' => 20],
]));
}
public function testMultipleDiscounts(): void
{
// 10% off = 90, 然后 10% off = 81
$this->assertSame(81.0, $this->calculator->calculate(100.0, [
['type' => 'percentage', 'value' => 10],
['type' => 'percentage', 'value' => 10],
]));
}
// === 边界条件 ===
public function testZeroBasePrice(): void
{
$result = $this->calculator->calculate(0.0, []);
$this->assertGreaterThanOrEqual(0.01, $result);
}
public function testNegativePriceThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->calculate(-10.0, []);
}
public function testDiscountCannotExceedPrice(): void
{
$result = $this->calculator->calculate(100.0, [
['type' => 'fixed', 'value' => 200],
]);
$this->assertGreaterThanOrEqual(0.01, $result);
}
public function testHundredPercentDiscount(): void
{
$result = $this->calculator->calculate(100.0, [
['type' => 'percentage', 'value' => 100],
]);
$this->assertSame(0.01, $result); // 最低价格
}
// === 精度测试 ===
public function testFloatingPointPrecision(): void
{
$result = $this->calculator->calculate(99.99, [
['type' => 'percentage', 'value' => 33],
]);
$this->assertEqualsWithDelta(66.99, $result, 0.01);
}
// === 数据提供器 ===
/**
* @dataProvider discountProvider
*/
public function testVariousDiscounts(
float $basePrice,
array $discounts,
float $expected
): void {
$result = $this->calculator->calculate($basePrice, $discounts);
$this->assertEqualsWithDelta($expected, $result, 0.01);
}
public static function discountProvider(): array
{
return [
'no discount' => [100.0, [], 100.0],
'10% off' => [100.0, [['type' => 'percentage', 'value' => 10]], 90.0],
'50% off' => [100.0, [['type' => 'percentage', 'value' => 50]], 50.0],
'fixed 20' => [100.0, [['type' => 'fixed', 'value' => 20]], 80.0],
'mixed' => [200.0, [
['type' => 'percentage', 'value' => 10],
['type' => 'fixed', 'value' => 15],
], 165.0],
];
}
}BDD 实践
Behat 安装与配置
bash
# 安装 Behat
composer require --dev behat/behat
# 初始化 Behat
./vendor/bin/behat --init
# 验证
./vendor/bin/behat --versionyaml
# behat.yml
default:
suites:
default:
contexts:
- FeatureContext
- App\Tests\Behat\UserContext
formatters:
pretty:
verbose: true
paths: falseGherkin 语法
gherkin
# features/user_registration.feature
Feature: 用户注册
作为系统用户
我需要能够注册新账户
以便使用系统的各项功能
Background:
Given 系统已初始化
Scenario: 成功注册新用户
Given 我在注册页面
When 我填写有效的注册信息:
| 字段 | 值 |
| 用户名 | alice |
| 邮箱 | alice@test.com |
| 密码 | SecurePass123 |
And 我点击"注册"按钮
Then 我应该看到"注册成功"的消息
And 我应该被重定向到个人主页
Scenario: 使用已注册的邮箱注册
Given 系统中已存在用户 "alice@test.com"
When 我尝试使用相同的邮箱注册
Then 我应该看到"邮箱已被注册"的错误消息
Scenario: 注册信息不完整
When 我提交空的注册表单
Then 我应该看到验证错误:
| 字段 | 错误信息 |
| 用户名 | 用户名不能为空 |
| 邮箱 | 邮箱不能为空 |Behat Context
php
<?php
declare(strict_types=1);
namespace App\Tests\Behat;
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\TableNode;
use PHPUnit\Framework\Assert;
/**
* 用户相关的 Behat Context
*/
class UserContext implements Context
{
private UserService $userService;
private ?User $currentUser = null;
private ?\Throwable $lastError = null;
public function __construct()
{
$this->userService = new UserService(
new InMemoryUserRepository(),
new EmailValidator()
);
}
/**
* @Given 系统中已存在用户 :email
*/
public function thereIsExistingUser(string $email): void
{
$this->userService->createUser('Existing User', $email);
}
/**
* @When 我尝试使用相同的邮箱注册
*/
public function iTryToRegisterWithSameEmail(): void
{
try {
$this->userService->createUser('New User', 'alice@test.com');
} catch (\Throwable $e) {
$this->lastError = $e;
}
}
/**
* @Then 我应该看到:arg1 的错误消息
*/
public function iShouldSeeErrorMessage(string $message): void
{
Assert::assertNotNull($this->lastError, '预期有错误但未发生');
Assert::assertStringContainsString(
$message,
$this->lastError->getMessage()
);
}
/**
* @When 我填写有效的注册信息
*/
public function iFillValidRegistrationInfo(TableNode $table): void
{
$data = $table->getRowsHash();
// 处理表单数据
}
/**
* @Then 我应该看到"注册成功"的消息
*/
public function iShouldSeeSuccessMessage(): void
{
Assert::assertNotNull($this->currentUser, '用户未创建');
}
/**
* @Then 我应该看到验证错误
*/
public function iShouldSeeValidationErrors(TableNode $errors): void
{
foreach ($errors->getRows() as $row) {
Assert::assertStringContainsString($row[1], $this->lastError->getMessage());
}
}
}Codeception
bash
# 安装 Codeception
composer require --dev codeception/codeception --dev
# 初始化
./vendor/bin/codecept bootstrap
# 生成测试
./vendor/bin/codecept generate:test unit UserTest
./vendor/bin/codecept generate:cest acceptance LoginCestCodeception Acceptance Test
php
<?php
declare(strict_types=1);
namespace Tests\Acceptance;
use Codeception\Scenario;
use Codeception\Test\Unit;
/**
* Codeception Acceptance Test
*/
class LoginCest
{
public function tryToLogin(AcceptanceTester $I): void
{
$I->amOnPage('/login');
$I->fillField('email', 'alice@example.com');
$I->fillField('password', 'password123');
$I->click('登录');
$I->see('欢迎回来');
$I->seeInCurrentUrl('/dashboard');
}
public function tryToLoginWithWrongPassword(AcceptanceTester $I): void
{
$I->amOnPage('/login');
$I->fillField('email', 'alice@example.com');
$I->fillField('password', 'wrong-password');
$I->click('登录');
$I->see('邮箱或密码错误');
}
}实战示例
TDD 驱动 API 开发
php
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Services\InvoiceService;
use PHPUnit\Framework\TestCase;
/**
* TDD 驱动的发票服务开发
*/
class InvoiceServiceTest extends TestCase
{
private InvoiceService $service;
protected function setUp(): void
{
$this->service = new InvoiceService();
}
/**
* 测试1: 创建发票
*/
public function testCreateInvoice(): void
{
$invoice = $this->service->createInvoice([
'customer' => 'Alice',
'items' => [
['description' => '商品A', 'quantity' => 2, 'price' => 100],
['description' => '商品B', 'quantity' => 1, 'price' => 50],
],
]);
$this->assertSame('Alice', $invoice['customer']);
$this->assertSame(250.0, $invoice['total']);
$this->assertCount(2, $invoice['items']);
}
/**
* 测试2: 空发票项目
*/
public function testEmptyItemsInvoice(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('发票必须包含至少一个项目');
$this->service->createInvoice([
'customer' => 'Bob',
'items' => [],
]);
}
/**
* 测试3: 税费计算
*/
public function testTaxCalculation(): void
{
$invoice = $this->service->createInvoice([
'customer' => 'Charlie',
'items' => [
['description' => '商品', 'quantity' => 1, 'price' => 100],
],
'tax_rate' => 10,
]);
$this->assertSame(110.0, $invoice['total_with_tax']);
}
/**
* 测试4: 折扣应用
*/
public function testDiscountApplication(): void
{
$invoice = $this->service->createInvoice([
'customer' => 'Dave',
'items' => [
['description' => '商品', 'quantity' => 1, 'price' => 200],
],
'discount' => ['type' => 'percentage', 'value' => 15],
]);
$this->assertSame(170.0, $invoice['total']);
}
/**
* 测试5: 发票编号生成
*/
public function testInvoiceNumberGeneration(): void
{
$invoice1 = $this->service->createInvoice([
'customer' => 'Eve',
'items' => [
['description' => '商品', 'quantity' => 1, 'price' => 100],
],
]);
$this->assertMatchesRegularExpression(
'/^INV-\d{4}-\d{4}$/',
$invoice1['invoice_number']
);
}
}团队 TDD 实践
开发工作流
1. 产品经理 / Tech Lead 编写 BDD 场景(Behat Feature 文件)
2. 开发者编写 PHPUnit 测试(RED)
3. 开发者实现功能(GREEN)
4. 开发者重构代码(REFACTOR)
5. CI 运行所有测试
6. Code Review 关注测试覆盖率和质量PHPUnit 分组
xml
<!-- phpunit.xml -->
<phpunit>
<groups>
<include>
<group>tdd</group>
<group>regression</group>
</include>
<exclude>
<group>slow</group>
</exclude>
</groups>
</phpunit>php
<?php
declare(strict_types=1);
/**
* @group tdd
*/
class FeatureTest extends TestCase
{
// TDD 驱动的新功能测试
}
/**
* @group slow
*/
class SlowIntegrationTest extends TestCase
{
// 耗时的集成测试
}注意事项
TDD 常见陷阱
- 过度设计:TDD 可能导致过度设计,应根据实际需求调整
- 测试脆弱:过于耦合实现细节的测试容易在重构时失效
- 忽视架构:TDD 不能替代架构设计思考
- 团队适应性:TDD 需要团队整体适应,单人实践效果有限
BDD 注意事项
- 场景粒度:Behat 场景不应过于细节化,应描述业务行为
- 维护成本:Gherkin 文件需要与代码同步更新
- 执行速度:BDD 测试通常较慢,应合理分组
最佳实践
1. TDD 适用场景
| 场景 | 适合 TDD | 说明 |
|---|---|---|
| 核心业务逻辑 | 是 | 最适合 TDD |
| 数据处理/转换 | 是 | 输入输出明确 |
| 算法实现 | 是 | 规则清晰 |
| UI 交互 | 否 | 不适合 TDD |
| 探索性开发 | 否 | 需求不明确时 |
2. 团队实践建议
- 从小项目开始练习 TDD
- 结对编程 + TDD 效果更好
- Code Review 时关注测试质量
- 持续积累测试模式
3. 结合 CI/CD
yaml
# GitHub Actions 工作流
name: TDD Pipeline
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
coverage: xdebug
- name: Install
run: composer install --prefer-dist
- name: Unit Tests (TDD)
run: ./vendor/bin/phpunit --testsuite unit --coverage-text
- name: Integration Tests
run: ./vendor/bin/phpunit --testsuite integration
- name: BDD Tests
run: ./vendor/bin/behat --no-interaction下一节
阶段 15 已全部完成,继续学习其他章节。