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

一、为什么 PHP 项目必须认真对待单元测试
很多团队对“测试”的印象停留在 QA 同学点一遍页面的阶段,这种人工回归在迭代节奏快、接口数量多的项目里几乎不可能稳定。单元测试的核心价值不是“证明代码没错”,而是把“代码行为是否符合预期”变成可自动化、可重复、可量化的资产。一旦你有了几百个可以在两秒内跑完的用例,重构时就敢于动手,PR 审查时也敢于合并。
具体到 PHP 生态,单元测试还能带来三个隐性收益:
- 倒逼依赖解耦:写不出测试的类,往往是因为它把数据库、HTTP 客户端、文件系统都 new 进来了,这种“上帝类”正是需要拆分的信号。
- 形成活文档:用例命名 + 断言本身就是对业务规则最精确的描述,比 README 更不容易过时。
- 加速反馈环:本地一次
1phpunit --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 |
即可看到红绿反馈。这里有几个细节值得强调:
- 用例命名用
1test_方法_场景_预期
的 snake_case,CI 失败时一眼能看懂断言意图,比
1testTax1这种废话命名强得多。
- 浮点比较用
1assertSame
配合
1round(),避免 0.1+0.2 这类经典精度坑;如果业务允许误差,用
1assertEqualsWithDelta。
-
1expectException
必须放在调用前,它本质是注册一个“接下来这次调用应该抛什么”的期望,放在后面是无效的。
三、依赖隔离:用 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 用法要点:
-
1shouldReceive('deduct')->once()
声明这个方法在本次用例中应当被调用恰好一次,没调用或调用多次都会失败——这是验证“协作关系”而非“内部实现”的关键。
-
1with(...)
精确匹配参数。
1m::on(callable)用闭包做宽松匹配,适合参数里有动态生成的字段(比如 orderId)。
-
1shouldNotReceive('info')
是反向断言,确保库存不足时不会写日志,这能防住“扣减失败但记了成功日志”这类隐藏 bug。
-
1MockeryPHPUnitIntegration
trait 自动在每个用例后调用
1m::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 里单独跑随机失败的用例若干次,确认是时序还是数据污染。给
1phpunit
加
1--order-by=random能尽早暴露用例间隐式依赖。
- 慢测试清单:用
1--log-junit
输出 XML,再用脚本按耗时排序,前三名往往是没 mock 好的 HTTP 调用或没清理的临时文件,改一处提速几秒。
把“测试时长”作为 CI 门禁指标,比卡覆盖率更有用——一个跑 30 秒的套件开发者愿意在本地跑,跑 5 分钟的就只会丢给 CI,反馈环就断了。
结语
PHP 单元测试不是“写完功能再补的功课”,而是项目架构健康度的实时体检。从最小可运行用例起步,用 Mockery 划清副作用边界,借数据提供器压缩重复,再以 CI 阈值和测试金字塔把质量内嵌到开发流程里——这套组合拳在团队规模从 3 人到 30 人都同样适用。投入两周搭建测试体系,换来的是后续每一次重构都敢于下手的底气,这笔账怎么算都不亏。
汤不热吧