《PHP 测试入门:PHPUnit 与测试替身》
要点
PHP 的测试生态绕不开 PHPUnit:11.0 于 2024 年 2 月 GA,一年来迭代到 11.x,框架类项目几乎都以它为底座。这篇做四件事——装好并跑通第一条用例、把数据提供器用顺、分清 stub 和 mock 两种替身、最后给MySQL 队列实战篇的队列逻辑补上两条真测试。
本文不做:Laravel 的测试封装(TestCase 基类、RefreshDatabase 那套,属于 Laravel 基础篇之后的进阶)、Pest 的语法糖、端到端浏览器测试。本文只讲 PHPUnit 11 原生能力,示例全是纯 PHP 项目口径,Laravel 项目里这些知识原样适用。
安装与第一条用例
PHPUnit 11 要求 PHP 8.2+,Composer 安装到开发依赖:
composer require --dev phpunit/phpunit
vendor/bin/phpunit --generate-configuration # 交互式生成 phpunit.xml
vendor/bin/phpunit # 跑全部测试
生成的 phpunit.xml 里两个关键项:bootstrap="vendor/autoload.php" 让测试文件能直接 use 业务类;tests 目录声明了扫描范围。约定先记死:类名以 Test 结尾的才被识别为测试类,方法名以 test 开头的才被识别为用例,文件放 tests/ 目录。目录结构上,业务代码进 src/ 并在 composer.json 的 psr-4 里登记 “App\”: “src/”,测试走 autoload-dev 登记 “App\Tests\”: “tests/”——两份映射分开管,发布生产包时测试代码可以整体不带。测试类与被测类一一对应是最省心的摆法:src/Price.php 对 tests/PriceTest.php,文件名对上,报错栈里的类名与文件名就能互相印证。
第一条用例,配一个最朴素的价格函数:
<?php
declare(strict_types=1);
use PHPUnitFrameworkTestCase;
final class PriceTest extends TestCase
{
public function test_total_multiplies_price_by_quantity(): void
{
$this->assertSame(297, Price::total(99, 3));
}
}
预期输出:
PHPUnit 11.x by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.008, Memory: 8.00 MB
OK (1 test, 1 assertion)
验收动作和鸿蒙篇一个思路:先撞红。把 assertSame 的期望值改成 296 跑一遍,看清失败输出长什么样——PHPUnit 会打印期望值与实际值的差异行、失败文件与行号。见过红色的样子,后面 CI 里见到失败才知道从哪读起。
断言与数据提供器
日常用到的高频断言其实就四五个:assertSame(类型与值都等,全等语义)、assertEquals(松散比较,’1′ 和 1 相等,容易埋坑)、assertStringContainsString、expectException(异常用例声明在最前面)。我的默认选择是 assertSame:只有类型都对才算对,浮点、字符串、整数的隐式转换问题在断言这一层就暴露。
同一个函数要测一堆输入组合时,用数据提供器而不是复制粘贴用例:
final class PriceTest extends TestCase
{
#[DataProvider('provideCases')]
public function test_total(int $price, int $qty, int $expected): void
{
$this->assertSame($expected, Price::total($price, $qty));
}
public static function provideCases(): array
{
return [
'正常乘法' => [99, 3, 297],
'数量为 0' => [99, 0, 0],
'单价为 0' => [0, 5, 0],
];
}
public function test_negative_price_throws(): void
{
$this->expectException(InvalidArgumentException::class);
Price::total(-1, 1);
}
}
三个语法点:提供器方法必须是 static——PHPUnit 10 起对此收紧,11 里写成实例方法会直接告警;数组键名是给失败报告看的用例名,失败时终端直接显示”数据集 单价为 0 失败”,定位比下标快得多;异常用例不写断言体,expectException 声明在前、触发调用在后,顺序反了测试会静默通过——这是最阴的一类假绿。每条提供器数据独立成一个用例,哪条红看哪条,不用像循环遍历那样红一条就得改一轮。
提供器的规模控制有一条经验线:三到八条覆盖”正常路径、边界值、已知反例”就收手。条数堆到几十条的数据提供器读起来像规格说明书,维护者不敢删也说不清每条的意图——那是把验收标准写错了地方。真正复杂的组合场景,该往下沉一层写成独立的测试方法,名字里带场景,而不是往数据表里继续加行。
测试替身:stub 与 mock 分开用
测试替身的核心问题不是 API,是”你要替身回答什么”。stub 只负责返回值,不关心被谁调、调几次;mock 额外声明”必须被调一次、参数是什么”,调用不发生测试就红。分不清这两者的项目,通常测试写出来既慢又脆。
替身能插进来的前提是依赖注入。业务类把发邮件这种副作用做成构造参数:
interface Mailer
{
public function send(string $to, string $body): bool;
}
final class OrderService
{
public function __construct(private Mailer $mailer) {}
public function place(array $order): bool
{
// 省略库存与价格逻辑
return $this->mailer->send($order['email'], '订单已创建');
}
}
stub 的用法,测”下单返回 true”:
$stub = $this->createStub(Mailer::class);
$stub->method('send')->willReturn(true);
$service = new OrderService($stub);
$this->assertTrue($service->place($order));
mock 的用法,测”下单后必须给这个邮箱发信”:
$mock = $this->createMock(Mailer::class);
$mock->expects($this->once())
->method('send')
->with('user@example.com', $this->stringContains('订单已创建'))
->willReturn(true);
$service = new OrderService($mock);
$this->assertTrue($service->place($order));
两者看着像,语义不同:stub 版测试在邮件发不出去时照样绿(因为没声明预期),mock 版会红。判断标准一句话——被测行为本身包含这个副作用时用 mock,副作用只是让流程走通时用 stub。”下单必须通知用户”是行为,该 mock;”中间某步需要一个对象占位”是装配,stub 就够。至于 spy(先执行、后核查)这种风格,PHPUnit 原生没有,要引 Mockery,本文不引——mock 的先声明后执行已经覆盖本文场景,多引一个库多一分心智负担。
替身还有一个不显眼的副作用要说破:替身替掉的不仅是实现,还是那份实现的真实缺陷。mock 的 Mailer 永远成功,真实 SMTP 的超时、退信、限流在测试里一概不存在——所以替身覆盖率越高的项目,越要保留一部分真依赖的集成用例兜底。替身是放大镜不是护目镜,它让单测聚焦行为,但不能替你证明系统整体能跑。
给队列实战补两条测试
队列实战篇的可靠性全压在两条 SQL 的语义上:CAS 抢占的”谁更新到谁拥有”,和幂等键唯一约束的”重复投递数据库兜底”。这两条值得用真数据库验证,但 MySQL 太重——用 SQLite 内存库建同构表,毫秒级起停:
final class QueueSqlTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->exec('CREATE TABLE jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idempotency_key TEXT NOT NULL UNIQUE,
payload TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
claimed_at TEXT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
available_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
$st = $this->pdo->prepare(
"INSERT INTO jobs (idempotency_key, payload) VALUES (:key, :payload)"
);
$st->execute([':key' => 'order:1001:close', ':payload' => '{}']);
}
public function test_两个worker抢同一行只有一人成功(): void
{
$claim = fn () => $this->pdo->prepare(
"UPDATE jobs
SET status = 1, claimed_at = CURRENT_TIMESTAMP,
attempts = attempts + 1
WHERE id = 1 AND status = 0 AND available_at <= CURRENT_TIMESTAMP"
);
$st1 = $claim(); $st1->execute();
$st2 = $claim(); $st2->execute();
$this->assertSame(1, $st1->rowCount()); // 第一个 worker 抢到
$this->assertSame(0, $st2->rowCount()); // 第二个没抢到
}
public function test_同幂等键第二次入队被拒绝(): void
{
$enqueue = fn () => $this->pdo->prepare(
"INSERT INTO jobs (idempotency_key, payload) VALUES ('order:1001:close', '{}')"
);
$this->expectException(PDOException::class);
$enqueue()->execute(); // setUp 里已插过同键,这次必撞 UNIQUE
}
}
预期输出:OK (2 tests, 2 assertions)。这两条测试的产出不止是绿色,补写时逼出两个真问题。其一,实战篇的 NOW() + INTERVAL 60 SECOND 是 MySQL 方言,SQLite 里没有 NOW(),重投 SQL 得写成 datetime(‘now’, ‘+60 seconds’)——队列的 SQL 原来并不跨库,是测试把这一点显式化了;要么接受”测试走同构方言、生产走 MySQL”,要么把方言差异收进一个小的 SQL 构造层。其二,isDupEntry 判的是驱动错误码 1062,那是 MySQL 的码,SQLite 撞唯一键时 errorInfo 里的码是另一套——这条纯函数测试提醒我们把”撞唯一键”的判断改成先比 SQLSTATE 再比驱动码的白名单,否则同构表上的测试根本复用不了它。
setUp 每条用例重建一次表结构不是浪费,是隔离的来源:内存库毫秒级起停,代价可忽略;换来的是用例之间零残留,谁改了状态谁自己带走。表结构在测试里手抄一份同构 CREATE TABLE 有维护成本——生产迁移改了字段,这里要跟着改。量小的时候抄写可控;字段多起来就该把建表 SQL 收进一个共享的 fixture 函数,测试和生产迁移共用同一份字段定义源。别为了省这两行把 fixture 做成全局静态缓存,缓存掉的隔离迟早连本带利还回去。
边界也直说:SQLite 内存库验证的是 SQL 语义,验证不了 MySQL 特有行为——SKIP LOCKED、行锁粒度、字符集排序这些仍要在真实 MySQL 上回归,别把”测试全绿”读成”生产无风险”。
常见坑
- assertEquals 当默认断言:’297’、297、297.0 全部相等,脚本语言的类型把戏会在断言层被悄悄放过。默认 assertSame,需要松散语义再显式降级。
- 异常用例顺序写反:expectException 声明在触发语句之后,异常抛出时断言还没挂上,测试直接报未捕获异常;或者更糟——没抛异常时测试通过,形成假绿。
- mock 掉实现类而不是接口:createMock 作用于具体类会把构造逻辑也替掉,行为和生产不一致;替身建立在接口上,注入的是”同形状的另一份实现”。
- 提供器数据含浮点再 assertEquals:浮点等值比较要 assertSame 配整数化处理,或直接避开浮点(金额先转分)。
- 全局装了一份 PHPUnit 又用 vendor/bin:两套版本混着跑,报错信息对不上文档。只用 vendor/bin/phpunit,版本由 composer.lock 锁死。
验收清单
- phpunit.xml 由 –generate-configuration 生成,bootstrap 指向 vendor/autoload.php,vendor/bin/phpunit 一条命令全绿。
- 至少一个数据提供器用例:键名是中文可读描述,且提供器方法是 static。
- 至少一个异常用例,expectException 声明在触发之前;故意去掉触发语句跑一遍确认变红。
- stub 与 mock 各用一次,且 mock 验证的是业务行为(once + with),不是占位装配。
- 队列两条 SQL 语义测试在 SQLite 内存库上通过;把 CAS 的 WHERE 条件删掉 status = 0 再跑,第一条必须变红。
- 测试文件里 grep 不到 var_dump 和 usleep——用断言表达结果,不用等待凑稳定性。
路线位置上,这篇是 PHP 系列”实战之后”的测试站:先让队列实战跑起来,再把本文的两条 SQL 测试叠上去;框架项目里怎么组织测试目录和基类,从Laravel 基础篇的工程结构接着往下走。
本文数据核对时间:2025-02-07:PHPUnit 11(11.0.0 于 2024-02-02 GA,文中 ^11 安装口径为 11.x 最新补丁)+ PHP 8.3/8.4(8.4 于 2024-11-21 GA,8.2 为 PHPUnit 11 下限)+ Composer 2.x;测试替身与 #[DataProvider] 属性均为 PHPUnit 10+ 原生 API。