Skip to content

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

  1. Red(红):编写一个会失败的测试(定义需求)
  2. Green(绿):编写最少量的代码使测试通过
  3. 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 found
php
<?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 --version
yaml
# behat.yml
default:
  suites:
    default:
      contexts:
        - FeatureContext
        - App\Tests\Behat\UserContext
  formatters:
    pretty:
      verbose: true
      paths: false

Gherkin 语法

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 LoginCest

Codeception 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 常见陷阱

  1. 过度设计:TDD 可能导致过度设计,应根据实际需求调整
  2. 测试脆弱:过于耦合实现细节的测试容易在重构时失效
  3. 忽视架构:TDD 不能替代架构设计思考
  4. 团队适应性:TDD 需要团队整体适应,单人实践效果有限

BDD 注意事项

  1. 场景粒度:Behat 场景不应过于细节化,应描述业务行为
  2. 维护成本:Gherkin 文件需要与代码同步更新
  3. 执行速度: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 已全部完成,继续学习其他章节。

参考链接