PHP 测试体系实践:PHPUnit、Pest、Mock 与集成测试全攻略

系统讲解 PHP 测试工程化:从 PHPUnit 基础断言到数据提供器,从 Mock 对象到 Test Double,从单元测试到集成测试与浏览器级测试,再到 Pest 现代语法的对比,最后给出测试金字塔与 CI 门禁的最佳实践。

引言

测试常被当作「写完代码之后的加分项」,但真正健康的 PHP 项目里,测试是代码质量的护栏:它让重构不心惊胆战,让新人不因改坏旧功能被骂,让 CI 在合并前挡住一半以上的回归。

本文覆盖 PHP 测试的完整工具链:PHPUnit(事实标准)的基础用法、数据提供器与测试组织;Mock 与 Test Double 如何在单元测试里隔离外部依赖;集成测试怎么连真实数据库和 HTTP;现代选择 Pest 如何用更优雅的语法让测试像自然语言;最后落在一张「测试金字塔」与 CI 门禁实践上。无论你维护 Laravel 还是裸 PHP 库,都能直接套用。

关联阅读:https://plumephp.com/php8-modern-features/ 的强类型与只读类是写出可测试领域模型的前提;https://plumephp.com/php-composer-package-development/ 演示了包的测试结构。


目录


1. 测试金字塔与测试类型全景

1.1 测试金字塔

      /\
     /  \      E2E / 浏览器测试(少而精)
    /----\     集成测试(适量)
   /------\    单元测试(最多,最快)
  /--------\
层级速度稳定性覆盖面数量
单元测试毫秒级高单函数/类最多
集成测试秒级中模块与 DB/IO适量
E2E 测试秒-分钟低完整链路最少

1.2 按「成本-收益」分配

  • 单元测试:核心领域逻辑,纯函数最易测。
  • 集成测试:仓储、HTTP 控制器、迁移。
  • E2E 测试:关键用户旅程(下单、支付)。

2. PHPUnit 基础:断言、注解与测试组织

2.1 最小测试用例

use PHPUnit\Framework\TestCase;

final class DiscountCalculatorTest extends TestCase
{
    public function test_applies_percent_discount(): void
    {
        $calc = new DiscountCalculator();
        $result = $calc->apply(100.0, 20);

        $this->assertSame(80.0, $result);
    }
}

2.2 常用断言

断言检查
assertSame严格相等(类型+值)
assertEquals宽松相等(1.0 == ‘1’)
assertContains数组包含元素
assertInstanceOf类型实例
assertThrows抛异常
assertMatchesRegularExpression正则匹配

2.3 测试命名与组织

tests/
├── Unit/
│   ├── DiscountCalculatorTest.php
│   └── OrderTest.php
├── Feature/
│   └── OrderApiTest.php
└── TestCase.php
  • 一个类对应一个测试文件,ClassNameTest。
  • test_ 前缀或 #[Test] 属性(PHPUnit 10+)定义用例。

2.4 setup 与 teardown

final class OrderRepositoryTest extends TestCase
{
    private OrderRepository $repo;

    protected function setUp(): void
    {
        $this->repo = new OrderRepository($this->fakeDb());
    }
}

3. 数据提供器与参数化测试

3.1 数据提供器

同一逻辑用多组输入验证:

final class MoneyFormatterTest extends TestCase
{
    #[DataProvider('centsProvider')]
    public function test_formats_cents(int $cents, string $expected): void
    {
        $this->assertSame($expected, MoneyFormatter::format($cents));
    }

    public static function centsProvider(): array
    {
        return [
            'zero'         => [0, '0.00'],
            'simple'       => [1234, '12.34'],
            'with negative' => [-500, '-5.00'],
        ];
    }
}

3.2 数据集命名的好处

命名让失败信息可读:assertSame('0.00','0.0') 会带上「zero」这个标签。


4. Test Double:Mock、Stub、Spy 与虚拟对象

4.1 Test Double 分类

类型用途
Stub返回预设数据,验证返回值
Mock验证「是否以特定方式被调用」
Spy记录调用供事后断言
Fake真实行为的轻量替代(内存仓储)
Dummy只占位,从不真正使用

4.2 用 Mock 验证协作

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

final class OrderServiceTest extends TestCase
{
    public function test_payment_gateway_charged_once(): void
    {
        /** @var PaymentGateway&MockObject $gateway */
        $gateway = $this->createMock(PaymentGateway::class);
        $gateway->expects($this->once())
                ->method('charge')
                ->with($this->callback(fn ($amount) => $amount > 0))
                ->willReturn(true);

        (new OrderService($gateway))->place(250.0);

        // expects(...)->once() 若未被调用,测试即失败
    }
}

4.3 Stub 返回预设

$gateway = $this->createStub(PaymentGateway::class);
$gateway->method('charge')->willReturn(true);
// 只关心返回值,不验证调用方式

4.4 用 Fake 替换真实 IO

final class InMemoryOrderRepository implements OrderRepository
{
    private array $orders = [];
    public function save(Order $o): void { $this->orders[$o->id] = $o; }
    public function find(int $id): ?Order { return $this->orders[$id] ?? null; }
}
// 测试用它,零 IO、零网络

4.5 Mock 的纪律

  • 只 mock 外部边界(网关、仓储、HTTP),不 mock 内部逻辑。
  • 过度 mock 会让测试与实现耦合,重构就废。

5. 单元测试实战:隔离外部依赖

5.1 目标:测「纯逻辑」

把副作用隔离到边界对象,核心逻辑保持无 IO、可预测:

final class PriceCalculator
{
    public function __construct(
        private TaxResolver $tax,
    ) {}

    public function total(Item $item): Money
    {
        return $item->price()->add(
            $this->tax->rateFor($item->country())->apply($item->price())
        );
    }
}

5.2 测试这个核心逻辑

final class PriceCalculatorTest extends TestCase
{
    public function test_total_includes_tax(): void
    {
        $tax = $this->createStub(TaxResolver::class);
        $tax->method('rateFor')->willReturn(new TaxRate(0.1));

        $calc = new PriceCalculator($tax);
        $total = $calc->total(new Item(new Money(100), 'CN'));

        $this->assertEquals(110.0, $total->amount());
    }
}

5.3 测试优先写法的收益

  1. 被迫把 IO 边界抽象出来 → 代码解耦。
  2. 测试运行毫秒级 → 频繁运行 → 及时反馈。
  3. 核心逻辑变更 → 测试立刻标红。

6. 集成测试:真实数据库与 HTTP

6.1 数据库测试(Laravel 风格)

use Illuminate\Foundation\Testing\RefreshDatabase;

final class OrderApiTest extends TestCase
{
    use RefreshDatabase;   // 每个用例重建库结构

    public function test_orders_list_returns_json(): void
    {
        Order::factory()->count(3)->create();

        $response = $this->getJson('/api/orders');

        $response->assertOk()
                 ->assertJsonCount(3, 'data')
                 ->assertJsonPath('data.0.status', 'pending');
    }
}

6.2 事务测试(回滚而非重建)

use Illuminate\Foundation\Testing\DatabaseTransactions;

// 用例间用事务包裹,结束回滚,比重建快

6.3 HTTP 与外部服务

// Mock HTTP 客户端,模拟外部 API
use Illuminate\Support\Facades\Http;

public function test_payment_webhook_signature(): void
{
    Http::fake([
        'payments.example.com/*' => Http::response(['ok' => true], 200),
    ]);

    $this->postJson('/webhooks/payment', ['txn' => 'T-001']);
}

6.4 集成测试的取舍

  • 优点:验证「接线」是否正确(路由→控制器→仓储→DB)。
  • 成本:慢、依赖环境(DB、Redis)。
  • 策略:关键链路做集成,其余用单元测试。

7. Pest:现代语法的测试体验

7.1 Pest 让测试像句子

// Pest 语法(PHPUnit 之上的 DSL)
use function Pest\it;

it('applies percent discount', function () {
    $calc = new DiscountCalculator();

    expect($calc->apply(100, 20))->toBe(80.0);
});

7.2 更丰富的 expect API

expect($array)->toHaveCount(3)
    ->and($result)->toBeGreaterThan(0)
    ->and($callback)->toThrow(InvalidArgumentException::class);

7.3 Pest 与 PHPUnit 的对比

维度PHPUnitPest
语法类 + 方法 + 断言函数式 it/expect
学习曲线略陡更友好
生态最广兼容 PHPUnit 断言
适用大型传统项目新项目、注重可读性

两者可共存:Pest 底层就是 PHPUnit,phpunit.xml 同用。


8. CI 门禁与覆盖率策略

8.1 CI 中的测试流程

# GitHub Actions
- run: composer install --prefer-dist
- run: php artisan test --parallel
- run: vendor/bin/phpunit --coverage-text --coverage-clover build/clover.xml
- uses: codecov/codecov-action@v4

8.2 覆盖率策略

  • 行覆盖率不是唯一目标:60-80% 覆盖核心逻辑即可,别为凑数测 getter。
  • 关键路径必测:支付、鉴权、数据一致性。
  • 用突变测试校验有效性:infection 能发现「没断言的测试」。
composer require --dev infection/infection
vendor/bin/infection --min-msi=80

8.3 门禁设置

门槛建议
单元+集成全绿合并前必过
覆盖率下降PR 对比,禁止明显下滑
关键路径无测试人工 review 把关

9. 测试反模式与改进路径

9.1 常见反模式

反模式问题改进
测实现细节重构必红测行为契约
过度 mock测试成摆设只 mock 边界
长测试用例难读难定位拆成多个小用例
只测快乐路径漏异常补边界与异常用例
断言过少假绿覆盖关键行为

9.2 改进路径(渐进)

  1. 新代码先写测试(TDD 或至少「红-绿-重构」)。
  2. 给最痛的核心逻辑补单元测试(支付、价格、状态机)。
  3. 关键链路补集成测试,最终形成完整金字塔。
  4. 定期跑 infection 揪出无效测试。

9.3 一句话心法

测试不是为了「证明代码没问题」,而是为了让「改代码不再可怕」。 维护成本高的不是测试本身,而是测试与实现过度耦合。


延伸阅读

  • https://plumephp.com/php8-modern-features/ — 枚举、只读类让领域模型更可测
  • https://plumephp.com/php-composer-package-development/ — 包的测试与 CI 结构
  • PHPUnit 官方文档 与 Pest 文档

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. PHP 面向对象与设计模式:SOLID、常用模式与 Laravel 实践
  2. PHP 静态分析与代码质量:PHPStan、Psalm、Rector 与 CI 门禁
  3. PHP 部署运维实战:Nginx、PHP-FPM、Docker 与 CI/CD