欢迎光临

PHP 单元测试实战指南:PHPUnit 与 Mockery 从入门到企业级测试体系搭建

单元测试常常被 PHP 开发者视为“写完代码再补一下”的负担,但在中大型项目里,一套运行快、覆盖广、反馈清晰的测试体系,才是重构和持续交付的底气。本文从 PHPUnit 的最小可运行用例讲起,逐步引入 Mockery 处理依赖隔离,再到数据提供器、代码覆盖率、CI 集成与测试金字塔分层,给出一份可在真实项目里落地的工作流。所有示例基于 PHP 8.2+ 与 PHPUnit 10/11。

PHP PHPUnit 单元测试代码

一、为什么 PHP 项目必须认真对待单元测试

很多团队对“测试”的印象停留在 QA 同学点一遍页面的阶段,这种人工回归在迭代节奏快、接口数量多的项目里几乎不可能稳定。单元测试的核心价值不是“证明代码没错”,而是把“代码行为是否符合预期”变成可自动化、可重复、可量化的资产。一旦你有了几百个可以在两秒内跑完的用例,重构时就敢于动手,PR 审查时也敢于合并。

具体到 PHP 生态,单元测试还能带来三个隐性收益:

  • 倒逼依赖解耦:写不出测试的类,往往是因为它把数据库、HTTP 客户端、文件系统都 new 进来了,这种“上帝类”正是需要拆分的信号。
  • 形成活文档:用例命名 + 断言本身就是对业务规则最精确的描述,比 README 更不容易过时。
  • 加速反馈环:本地一次
    1
    phpunit --filter

    0.5 秒出结果,比“改一行、刷一次浏览器、看一眼报错”快一个数量级。

二、PHPUnit 基础:最小可运行用例

PHPUnit 通过 Composer 安装即可,建议作为 dev 依赖:


1
composer require --dev phpunit/phpunit ^11.0

在项目根目录创建

1
phpunit.xml

(PHPUnit 10 起推荐用

1
phpunit.xml.dist

并迁到 XSD 校验),最小配置如下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
    <source>
        <directory>src</directory>
    </source>
</phpunit>

注意 PHPUnit 10 起,

1
<source>

节点取代了旧的

1
<filter>

,用于声明“被测代码”范围,覆盖率统计时会用到。

写一个最简单的用例,验证一个价格计算类:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php
// src/PriceCalculator.php
namespace App\Pricing;

class PriceCalculator
{
    public function __construct(private readonly float $taxRate) {}

    public function withTax(float $net): float
    {
        if ($net < 0) {
            throw new \InvalidArgumentException('净价不能为负');
        }
        return round($net * (1 + $this->taxRate), 2);
    }
}

// tests/Unit/PriceCalculatorTest.php
namespace Tests\Unit;

use App\Pricing\PriceCalculator;
use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    public function test_with_tax_returns_inclusive_price(): void
    {
        $calc = new PriceCalculator(0.13);
        self::assertSame(113.00, $calc->withTax(100.00));
    }

    public function test_negative_net_throws(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        new PriceCalculator(0.13)->withTax(-1.0);
    }
}

运行

1
vendor/bin/phpunit tests/Unit/PriceCalculatorTest.php

即可看到红绿反馈。这里有几个细节值得强调:

  • 用例命名用
    1
    test_方法_场景_预期

    的 snake_case,CI 失败时一眼能看懂断言意图,比

    1
    testTax1

    这种废话命名强得多。

  • 浮点比较用
    1
    assertSame

    配合

    1
    round()

    ,避免 0.1+0.2 这类经典精度坑;如果业务允许误差,用

    1
    assertEqualsWithDelta

  • 1
    expectException

    必须放在调用前,它本质是注册一个“接下来这次调用应该抛什么”的期望,放在后面是无效的。

三、依赖隔离:用 Mockery 替换外部协作

真实代码几乎不会这么干净——计算器依赖仓库,仓库依赖数据库。如果在测试里真的连 MySQL,那叫集成测试,速度慢、状态污染、CI 不稳定。单元测试的边界应该划在“纯逻辑”与“副作用”之间,副作用部分用替身(test double)替换。Mockery 是 PHP 生态里最顺手、API 最流畅的 mock 库:


1
composer require --dev mockery/mockery ^1.6

看一个典型场景:订单服务在发货前需要调用库存服务扣减库存,同时写一条审计日志。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
// src/Order/PlaceOrderService.php
namespace App\Order;

use App\Inventory\InventoryClient;
use App\Audit\AuditLogger;

class PlaceOrderService
{
    public function __construct(
        private InventoryClient $inventory,
        private AuditLogger $logger,
    ) {}

    public function place(string $sku, int $qty): string
    {
        $ok = $this->inventory->deduct($sku, $qty);
        if (!$ok) {
            throw new \RuntimeException("库存不足: {$sku}");
        }
        $orderId = uniqid('ord_');
        $this->logger->info('order_placed', ['sku' => $sku, 'qty' => $qty, 'id' => $orderId]);
        return $orderId;
    }
}

这个类把库存客户端和日志器通过构造函数注入进来,这正是可测试的标志。用 Mockery 写测试:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php
// tests/Unit/Order/PlaceOrderServiceTest.php
namespace Tests\Unit\Order;

use App\Audit\AuditLogger;
use App\Inventory\InventoryClient;
use App\Order\PlaceOrderService;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
use Mockery as m;

class PlaceOrderServiceTest extends TestCase
{
    use MockeryPHPUnitIntegration;

    public function test_place_returns_order_id_when_stock_ok(): void
    {
        $inventory = m::mock(InventoryClient::class);
        $inventory->shouldReceive('deduct')
            ->once()
            ->with('SKU-001', 2)
            ->andReturn(true);

        $logger = m::mock(AuditLogger::class);
        $logger->shouldReceive('info')
            ->once()
            ->with('order_placed', m::on(fn ($arr) => $arr['sku'] === 'SKU-001' && $arr['qty'] === 2));

        $service = new PlaceOrderService($inventory, $logger);
        $id = $service->place('SKU-001', 2);

        self::assertStringStartsWith('ord_', $id);
    }

    public function test_place_throws_when_out_of_stock(): void
    {
        $inventory = m::mock(InventoryClient::class);
        $inventory->shouldReceive('deduct')->andReturn(false);
        $logger = m::mock(AuditLogger::class);
        $logger->shouldNotReceive('info');

        $service = new PlaceOrderService($inventory, $logger);
        $this->expectException(\RuntimeException::class);
        $service->place('SKU-001', 99);
    }
}

几个 Mockery 用法要点:

  • 1
    shouldReceive('deduct')->once()

    声明这个方法在本次用例中应当被调用恰好一次,没调用或调用多次都会失败——这是验证“协作关系”而非“内部实现”的关键。

  • 1
    with(...)

    精确匹配参数。

    1
    m::on(callable)

    用闭包做宽松匹配,适合参数里有动态生成的字段(比如 orderId)。

  • 1
    shouldNotReceive('info')

    是反向断言,确保库存不足时不会写日志,这能防住“扣减失败但记了成功日志”这类隐藏 bug。

  • 1
    MockeryPHPUnitIntegration

    trait 自动在每个用例后调用

    1
    m::close()

    ,遗漏它会导致期望未验证、跨用例污染。

四、数据提供器:一份逻辑,多组输入

边界值、异常值、正常值往往只是同一逻辑的不同输入,复制粘贴十份用例既冗长又易错。数据提供器(

1
#[DataProvider]

)把“输入-期望”抽成表格,用例体只写一次断言:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
use PHPUnit\Framework\Attributes\DataProvider;

class PriceCalculatorTest extends TestCase
{
    #[DataProvider('taxCases')]
    public function test_with_tax(float $net, float $rate, float $expected): void
    {
        $calc = new PriceCalculator($rate);
        self::assertSame($expected, $calc->withTax($net));
    }

    public static function taxCases(): array
    {
        return [
            'zero rate'         => [0.00, 0.00, 0.00],
            'standard'          => [100.00, 0.13, 113.00],
            'high value'        => [9999.00, 0.06, 10598.94],
            'one cent'          => [0.01, 0.13, 0.01],
        ];
    }
}

每个 key 会出现在测试报告里,失败时直接看到是哪一组输入挂了,比

1
testTax1 / testTax2

可读得多。注意数据提供器在 PHPUnit 10 起必须是

1
static

,跨用例共享状态会破坏隔离性。

测试覆盖率与代码质量仪表盘

五、覆盖率:用作健康指标,而非 KPI

开启 Xdebug 或 PCOV 后,

1
--coverage-text

可以在终端输出覆盖率摘要。一个常见误区是把 100% 覆盖率当目标——它会逼出大量针对 getter/setter 的无意义用例,反而稀释信号。更务实的做法是设定差异化阈值

代码层 建议覆盖率 说明
领域逻辑/服务层 ≥ 85% 核心规则,必须严测
基础设施适配器 ≥ 50% 薄包装层,集成测试补位
控制器/入口 ≥ 60% 用功能测试覆盖路由+参数校验
DTO/Value Object 不强求 纯数据结构,断言隐含在调用方

1
phpunit.xml

里配置阈值,低于门槛则 CI 失败:


1
2
3
4
5
<coverage>
    <report>
        <html outputDirectory="build/coverage"/>
    </report>
</coverage>

然后在 CI 脚本里跑

1
vendor/bin/phpunit --coverage-clover build/clover.xml

,把

1
clover.xml

喂给 SonarQube 或 Codecov。覆盖率趋势图比绝对值更有价值——持续下降往往意味着新增代码没补测试。

六、CI 集成与测试金字塔分层

单元测试要发挥价值,必须嵌入 CI 流水线,并在每次 PR 上跑。GitHub Actions 的最小配置:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
name: tests
on: [pull_request]
jobs:
  phpunit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.2', '8.3']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: pcov
      - run: composer install --no-interaction --prefer-dist
      - run: vendor/bin/phpunit --coverage-clover build/clover.xml
      - uses: codecov/codecov-action@v4
        with:
          files: build/clover.xml

把测试按金字塔分层,避免“全量集成测试”拖垮反馈速度:

  • 单元测试(占比 ~70%):纯逻辑 + Mockery 替身,毫秒级,每个 PR 都跑。
  • 集成测试(占比 ~20%):用 Testcontainers 起真实 MySQL/Redis,验证仓储层 SQL 与迁移。分钟级,PR 触发或每日定时。
  • 端到端/功能测试(占比 ~10%):API 真实请求路径,验证鉴权、中间件链路。集成在 staging 环境跑。

七、常见反模式与重构建议

实际接手项目时常能见到几类典型坏味道,识别并修掉它们能让测试体系真正可用:

1. 测试里直接 new 数据库连接。看起来“测得真”,实则每次跑都污染数据,且跨用例状态不可控。正确做法是把 PDO 包装成

1
ConnectionInterface

,测试时注入内存版的 SQLite 或 mock,生产用真实 MySQL。

2. 一个测试方法断言十件事。失败时只报第一处,定位成本高。拆成多个

1
test_*

,每个聚焦一个行为,命名清晰胜过注释。

3. mock 了自己写的内部类。这等于在测“实现”而非“契约”,重构时整片测试爆炸。规则是:只 mock外部边界(HTTP 客户端、消息队列、第三方 SDK),领域对象用真实实例。

4. 用

1
setUp

做重活。每个用例都连一次 Redis,套件就慢得没法在本地跑。重资源放

1
setUpBeforeClass

或 fixture 工厂,并配合

1
@after

清理。

5. 把时间相关的断言写死成常量。跨时区或跨天运行就挂。用可注入的

1
ClockInterface

,测试时传固定时间,生产传系统时钟。

八、从能跑到跑得稳: Flake 与速度治理

当用例数量到上千时,最影响体验的不是覆盖率,而是稳定性和速度。两条经验:

  • 隔离 flaky 用例:CI 里单独跑随机失败的用例若干次,确认是时序还是数据污染。给
    1
    phpunit

    1
    --order-by=random

    能尽早暴露用例间隐式依赖。

  • 慢测试清单:用
    1
    --log-junit

    输出 XML,再用脚本按耗时排序,前三名往往是没 mock 好的 HTTP 调用或没清理的临时文件,改一处提速几秒。

把“测试时长”作为 CI 门禁指标,比卡覆盖率更有用——一个跑 30 秒的套件开发者愿意在本地跑,跑 5 分钟的就只会丢给 CI,反馈环就断了。

结语

PHP 单元测试不是“写完功能再补的功课”,而是项目架构健康度的实时体检。从最小可运行用例起步,用 Mockery 划清副作用边界,借数据提供器压缩重复,再以 CI 阈值和测试金字塔把质量内嵌到开发流程里——这套组合拳在团队规模从 3 人到 30 人都同样适用。投入两周搭建测试体系,换来的是后续每一次重构都敢于下手的底气,这笔账怎么算都不亏。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » PHP 单元测试实战指南:PHPUnit 与 Mockery 从入门到企业级测试体系搭建
分享到: 更多 (0)