使用 PHPUnit 进行单元测试与测试驱动开发实战

在现代 PHP 开发中,代码质量不再是“能跑就行”的底线要求,而是团队协作、长期维护和持续交付的核心保障。单元测试作为质量保障的第一道防线,能够帮助开发者在早期发现缺陷、重构代码时保持信心,并驱动出更清晰的接口设计。而 PHPUnit 作为 PHP 生态中最成熟的测试框架,几乎是每个 PHP 项目不可或缺的工具。本文将带你从零开始掌握 PHPUnit 的核心用法,并通过一个实战案例演示测试驱动开发(TDD)的完整流程。

为什么需要单元测试

很多开发者对单元测试的抵触来源于“写测试太耗时”的直觉。但真实情况是:没有测试的代码,修改成本会随时间指数级上升。当你接手一个没有测试的遗留系统时,任何一行改动都可能引发不可预知的连锁反应。单元测试带来的核心价值包括:

  • 快速反馈:几秒内验证代码逻辑是否正确,无需手动打开浏览器或调用接口。
  • 安全重构:有测试覆盖的代码可以放心调整内部实现,只要测试通过,行为就没有改变。
  • 活文档:测试用例本身就是对函数行为的精确描述,比注释更可靠。
  • 驱动设计:先写测试会迫使你从调用者角度思考接口,从而产生更解耦、更易用的代码。

安装与初始化 PHPUnit

在项目中使用 Composer 安装 PHPUnit(以 PHPUnit 10 为例):

composer require --dev phpunit/phpunit ^10.0

初始化配置文件:

./vendor/bin/phpunit --generate-configuration

生成的 phpunit.xml 中需要配置测试目录、自动加载和覆盖率白名单。一个典型的配置如下:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache">
    <testsuites>
        <testsuite name="Application Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

PHPUnit 核心概念速览

在进入实战之前,先快速了解几个关键概念:

  • TestCase:所有测试类都继承自 PHPUnit\Framework\TestCase
  • 断言(Assertion):如 assertSameassertTrueassertCount,用于验证实际结果是否符合预期。
  • 测试方法:以 test 开头或使用 #[Test] 注解的公有方法。
  • 数据提供器(Data Provider):用同一套逻辑测试多组输入,避免重复代码。
  • Mock 对象:模拟外部依赖,让测试只关注当前单元的逻辑。

实战:用 TDD 开发一个购物车折扣计算器

假设我们需要实现一个类 DiscountCalculator,规则是:购物车总金额满 200 元打 9 折,满 500 元打 8 折,不满 200 元不打折。

第一步:先写测试(红)

tests/DiscountCalculatorTest.php 中编写第一个测试:

<?php

use PHPUnit\Framework\TestCase;
use App\DiscountCalculator;

class DiscountCalculatorTest extends TestCase
{
    public function testNoDiscountBelow200(): void
    {
        $calculator = new DiscountCalculator();
        $this->assertSame(150.0, $calculator->calculate(150.0));
    }
}

运行 ./vendor/bin/phpunit,测试失败,因为 DiscountCalculator 类还不存在。这就是 TDD 的“红”阶段——先让测试失败,明确目标。

第二步:编写最小实现(绿)

创建 src/DiscountCalculator.php

<?php

namespace App;

class DiscountCalculator
{
    public function calculate(float $total): float
    {
        return $total;
    }
}

再次运行测试,通过。此时不要急于添加更多逻辑,先让当前测试通过即可。

第三步:补充更多测试用例

继续添加测试,覆盖折扣边界:

public function testDiscountAt200(): void
{
    $calculator = new DiscountCalculator();
    $this->assertSame(180.0, $calculator->calculate(200.0));
}

public function testDiscountAt500(): void
{
    $calculator = new DiscountCalculator();
    $this->assertSame(400.0, $calculator->calculate(500.0));
}

运行测试,两个新用例失败。现在修改实现:

public function calculate(float $total): float
{
    if ($total >= 500) {
        return $total * 0.8;
    }
    if ($total >= 200) {
        return $total * 0.9;
    }
    return $total;
}

所有测试通过,进入“重构”阶段。此时可以提取常量、消除魔法数字,而测试会保证行为不变。

第四步:使用数据提供器优化测试

当测试用例增多时,可以用数据提供器让代码更简洁:

/**
 * @dataProvider discountProvider
 */
public function testCalculateDiscount(float $input, float $expected): void
{
    $calculator = new DiscountCalculator();
    $this->assertSame($expected, $calculator->calculate($input));
}

public static function discountProvider(): array
{
    return [
        'below 200' => [150.0, 150.0],
        'exactly 200' => [200.0, 180.0],
        'between 200 and 500' => [300.0, 270.0],
        'exactly 500' => [500.0, 400.0],
        'above 500' => [1000.0, 800.0],
    ];
}

使用 Mock 隔离外部依赖

单元测试的核心是“单元”,当被测代码依赖数据库、HTTP 客户端或第三方服务时,应使用 Mock 替换真实依赖。例如,一个 OrderService 依赖 PaymentGateway

public function testOrderIsPaid(): void
{
    $gateway = $this->createMock(PaymentGateway::class);
    $gateway->expects($this->once())
            ->method('charge')
            ->with(100.0)
            ->willReturn(true);

    $service = new OrderService($gateway);
    $this->assertTrue($service->pay(100.0));
}

这样测试不依赖真实支付接口,运行速度快且结果稳定。

测试驱动开发的节奏与建议

TDD 的经典循环是 红 → 绿 → 重构

  1. :写一个失败的测试,明确需求。
  2. 绿:用最少的代码让测试通过,不追求完美。
  3. 重构:在测试保护下优化代码结构。

实践中需要注意几点:

  • 小步前进:每次只添加一个测试,避免一次性写大量测试后陷入调试泥潭。
  • 测试命名清晰:方法名应描述行为,如 testNoDiscountBelow200,而不是 testCalculate1
  • 避免测试实现细节:测试应关注输入输出,而不是内部调用了哪个私有方法。
  • 保持测试快速:单个测试应在毫秒级完成,否则开发者会不愿意频繁运行。

持续集成中的 PHPUnit

将 PHPUnit 接入 CI 流程是保证测试不被忽略的关键。在 GitHub Actions 中添加:

- name: Run tests
  run: ./vendor/bin/phpunit --coverage-text

配合 --coverage-text 可以直观看到覆盖率,但不要盲目追求 100% 覆盖率——覆盖关键业务逻辑和边界条件比覆盖所有 getter/setter 更有价值

总结

PHPUnit 不仅是一个测试工具,更是一种推动代码设计改进的思维方式。通过本文的实战,你应当已经掌握了 PHPUnit 的基本用法、Mock 技巧以及 TDD 的完整节奏。开始行动的最佳时机就是现在:为你的下一个函数先写一个测试,感受“红绿重构”带来的确定性与掌控感。随着测试套件的增长,你会发现自己对代码的每一次修改都更加从容,而这正是专业开发者与普通编码者之间的分水岭。

未经允许不得转载:任鹏个人博客 » 使用 PHPUnit 进行单元测试与测试驱动开发实战

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏