引入段
写 PHPUnit 测试这件事,我相信大多数 PHP 开发者都不太爱干。一个简单的"用户登录"用例,要写一个类、继承 TestCase、写一个 test 开头的方法、里面塞满 $this->assertXxx。稍微复杂的业务,测试代码比业务代码还长。
Pest 干的事很简单:把这些啰嗦全部吃掉,留下一个 it() 函数。它底层就是 PHPUnit,跑出来的报告、覆盖率、CI 集成都跟 PHPUnit 完全一样,但你写的时候心情完全不同。
Pest 3 在 2024 年 9 月的 Laracon US 上正式发布(截至本文写作时最新是 v3.8.6,Pest 4 也已经发布但要求 PHP 8.3+),是一个值得花一两个小时认真上手的版本。
安装
composer require pestphp/pest --dev --with-all-dependencies
Laravel 项目里通常还会装上 Laravel 插件:
composer require pestphp/pest-plugin-laravel --dev
初始化:
# Laravel 项目
php artisan pest:install
# 非 Laravel 项目
./vendor/bin/pest --init
基本 API:it() 和 expect()
对比看一眼就明白了。
PHPUnit 写法:
class UserTest extends TestCase
{
public function test_user_can_login(): void
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertStatus(200);
$this->assertAuthenticatedAs($user);
}
}
Pest 3 写法:
it('can login', function () {
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'password',
])->assertStatus(200);
expect(auth()->user())->id->toBe($user->id);
});
省掉了类的样板,断言变成可链式调用的 expect()。->id->toBe(...) 这种写法是合法的 —— Pest 通过 __get 取到 id 属性产生新的高层期望对象,再用 __call 链式调 toBe() 之类的断言,读起来像自然语言。
it() 和 test() 完全等价,挑顺手的用:
test('can register', function () {
// ...
});
it('can register', function () {
// ...
});
我更喜欢 it(),跑出来报告里显示 "it can register",读起来像英文句子。
跑一次 ./vendor/bin/pest,输出长这样:

这张截图里既有 Tests\Unit\CalculatorTest 全 PASS,也有 Tests\ArchTest 里一条架构断言失败 —— 注意 Pest 用红绿标签 + 高亮源码位置展示失败点,定位很快。
Dataset:数据驱动测试
一个典型场景:验证邮箱格式。PHPUnit 要写 dataProvider 注解,Pest 直接 ->with():
it('validates emails', function (string $email, bool $expected) {
expect(filter_var($email, FILTER_VALIDATE_EMAIL) !== false)->toBe($expected);
})->with([
['user@example.com', true],
['invalid', false],
['', false],
['no-at-sign.com', false],
]);
更可读的写法是给数据加 key(假设有个 NetworkConfig 业务类,网络名不能含 shell 元字符):
it('rejects invalid network names', function (string $network) {
$model = new NetworkConfig;
$model->network = $network;
$model->validate(); // 抛 InvalidArgumentException
})->with([
'semicolon injection' => 'net; rm -rf /',
'pipe injection' => 'net|cat /etc/passwd',
'dollar injection' => 'net$(whoami)',
'backtick injection' => 'net`id`',
])->throws(InvalidArgumentException::class);
跑出来报告会带 key,失败时一眼看出是哪条数据出问题 —— 这比 PHPUnit 默认的 "data set #2" 友好太多。
异常测试:throws()
PHPUnit 测异常要用 expectException(),放在测试方法开头。Pest 用链式 throws():
// 业务方法可能抛异常的场景(任何会在错误输入下抛异常的代码都行)
it('throws when image mode is invalid', function () {
FileUpload::make('photo')->imageEditorMode(0); // 假设的业务类
})->throws(InvalidArgumentException::class);
// 同时断言异常消息和错误码
it('throws with specific message', function () {
throw new RuntimeException('Custom error', 500);
})->throws(RuntimeException::class, 'Custom error', 500);
这套写法读起来就是 "它 throws InvalidArgumentException",非常自然。
架构测试:真正的杀手锏
前面那些语法糖都是锦上添花,架构测试(Architecture Testing)才是 Pest 3 真正的杀手锏。
什么叫架构测试?用代码去约束代码结构。比如"Controllers 必须是 final 类"、"不得使用 die()"、"所有类必须用严格类型"。这些规则传统上靠 code review 或 PHPStan 规则,Pest 让你直接写成测试。
最简单的用法 —— 五个内置 preset:
// tests/ArchTest.php
arch()->preset()->php(); // 通用 PHP 规范:禁用 die、var_dump 等
arch()->preset()->security(); // 安全规范:禁用 eval、md5 等
arch()->preset()->laravel(); // Laravel 约定:Controller 后缀、REST 方法名
arch()->preset()->strict(); // 严格类型 + final 类
arch()->preset()->relaxed(); // strict 的反向
一行 preset 背后是几十条规则,跑一次 ./vendor/bin/pest 就能扫整个项目。
自定义架构断言:
arch('controllers')
->expect('App\Http\Controllers')
->toBeClasses()
->toExtendNothing()
->toHaveSuffix('Controller')
->toUseStrictTypes();
arch('services no private methods')
->expect('App\Services')
->not->toHavePrivateMethods();
arch('use strict equality only')
->expect('App')
->toUseStrictEquality(); // 检查代码用的是 === 而不是 ==
我刚接入这个特性的时候,在老项目上跑 arch()->preset()->strict(),一次报了 80 多个错误。但从此 review 不用再吵"这里该不该加 declare(strict_types=1)",交给测试就行。这就是架构测试的价值 —— 把团队争议变成可执行的规则。
新的配置 API
Pest 2 的 uses() 还能用,但 Pest 3 推荐新写法:
// tests/Pest.php
// Pest 2 老写法
uses(TestCase::class, RefreshDatabase::class)->in('Features');
// Pest 3 新写法
pest()->extends(TestCase::class)
->use(RefreshDatabase::class)
->in('Features');
// 紧凑报告输出
pest()->printer()->compact();
新写法更清楚:extends 表继承或 trait 都能传(use 是它的别名),挑语义对得上的用就行。
打开 pest()->printer()->compact() 之后,输出变成一行点号 + 总结:

CI 日志里这种格式比整页 PASS 列表省事很多。
单测级 teardown:after()
Pest 3 给单个测试或 describe 加了 after():
it('writes a temp file', function () {
$path = sys_get_temp_dir().'/test-'.uniqid().'.txt';
file_put_contents($path, 'hello');
expect(file_exists($path))->toBeTrue();
})->after(function () {
// 这个测试跑完后清理(注意 unlink 不解析通配符,要遍历)
foreach (glob(sys_get_temp_dir().'/test-*.txt') as $file) {
@unlink($file);
}
});
describe('integration', function () {
it('does A', function () {/* ... */});
it('does B', function () {/* ... */});
})->after(function () {
// 整个 describe 跑完执行一次,不是每个 it 都跑
});
踩坑记录
Snapshot Testing 不是 Pest 内置的
很多教程文章说 "Pest 内置 Snapshot Testing",这是错的。Pest 官方没有 snapshot,业界标准是 Spatie 的第三方插件:
composer require spatie/pest-plugin-snapshots --dev
然后:
use function Spatie\Snapshots\assertMatchesSnapshot;
it('matches snapshot', function () {
$output = renderTemplate($data);
assertMatchesSnapshot($output);
});
第一次跑会生成快照文件,之后每次跑都跟快照对比。这个能力在测试 HTML 输出、PDF 生成、长 JSON 结构时非常有用,但你要装插件,不是 Pest 自带的。
Mutation Testing:开箱即用
跟 Snapshot 不一样,Mutation Testing 是 Pest 3 自带的(pestphp/pest-plugin-mutate 是 Pest 3 的硬依赖,装 Pest 就有,不用单独 require):
./vendor/bin/pest --mutate
它会修改你的源码(把 > 改成 <、&& 改成 ||、删掉一行),然后看测试有没有抓出来。抓不出来说明测试写得不够细。
跑一次 ./vendor/bin/pest --mutate 的输出:

这里 10 个变异全被测试抓出来了(Score: 100.00%),每个变异对应源码里的一处改动,比如 PlusToMinus(把加号改成减号)、IfNegated(if 条件取反)。如果有任何变异没被测试抓出来,Score 就会跌下去,这时候该补测试用例了。
Laravel 模块化项目的坑
如果用 nwidart/laravel-modules 把测试放在 Modules/X/Tests/ 而不是默认的 tests/Feature 或 tests/Unit,Pest 不会自动应用 tests/Pest.php 里的 extends(TestCase::class) 配置。
症状很迷惑 —— Facade 用不了、config() 读不到、.env 没生效,看上去像 Laravel 容器没启动。其实就是没启动。
解决办法是在 tests/Pest.php 里显式声明路径:
pest()
->extends(TestCase::class)
->use(RefreshDatabase::class)
->in('../Modules/*/Tests/Feature');
我踩过这个坑,排查了大半天,最后才意识到 Pest 默认只在 tests/ 目录下应用配置。
升级 Pest 2 → Pest 3 清单
- PHP 必须 8.2+(从 8.1 提升)
- Laravel 必须 11+(因为 Collision 8 要求)
- 所有 Pest 插件必须升到
^3.0(pest-plugin-laravel、pest-plugin-watch 等) -
tap()被彻底移除,改用defer() -
toHaveMethod/toHaveMethods不再接受对象,只接受命名空间或类名
升级前先 commit 干净,跑一次 composer why nunomaduro/collision 看依赖链,避免被别的包卡住。
在 ThinkPHP 项目里用 Pest(诚实版)
国内用 ThinkPHP 的开发者比 Laravel 多得多,所以这节单独说一下。但先讲清楚现状:pestphp/pest-plugin-laravel 存在,对应的 pest-plugin-thinkphp 不存在。我搜遍 GitHub,公开项目里同时使用 topthink/framework 和 pestphp/pest 的数量是 0。官方的 topthink/think-testing 包最后更新停在 2018 年的 v2.0.5,只支持 ThinkPHP 5.1,跟 TP 6/8 完全装不上。
Pest 本质是 PHPUnit 的 DSL 包装,而 ThinkPHP 8 的测试基础就是 PHPUnit,所以理论可行,但要全手工配。三件事必须做:
# 1. composer.json 锁 PHP 8.2+(TP 8 最低 8.0,Pest 3 最低 8.2)
# 2. 装 Pest(Pest 自带 PHPUnit 11,不会跟框架的 require-dev 冲突)
composer require pestphp/pest:^3 --dev --with-all-dependencies
第三件事 —— 自己写一个 TestCase 基类,因为没有现成的可继承:
<?php
// tests/Support/ThinkTestCase.php
namespace Tests\Support;
use think\App;
use think\Container;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class ThinkTestCase extends BaseTestCase
{
protected App $app;
protected function setUp(): void
{
parent::setUp();
$this->app = new App(dirname(__DIR__, 2));
Container::setInstance($this->app);
$this->app->initialize();
}
protected function tearDown(): void
{
Container::setInstance(null); // 容器是单例,必须清理
parent::tearDown();
}
}
然后 tests/Pest.php 里把它挂上去:
pest()->extend(\Tests\Support\ThinkTestCase::class)->in('Unit');
踩坑清单(都是真的):
-
topthink/think-testing别装,装上也跑不起来(7 年没更新) - ThinkPHP 没有 RefreshDatabase 这种东西,数据库测试要自己写事务回滚或用独立的测试库
-
App::initialize()会去读.env、config/、app/common.php,测试环境必须保证这些文件存在 - ThinkPHP 容器是全局单例,
tearDown里必须Container::setInstance(null),否则测试之间互相污染
我的态度:能跑,但不推荐生产用。生态空白意味着你遇到任何问题都没有先例可查,所有坑都得自己趟。如果是新项目又想用 Pest,选 Laravel 收益会高很多。ThinkPHP 项目要写测试,目前最稳的路还是直接用 PHPUnit。
关于 Pest 4
本文写作时(2026 年 6 月),Pest 4 已经发布,基于 PHPUnit 12,要求 PHP 8.3+,加了 Browser Testing。新项目 PHP 版本够的话直接上 Pest 4 没问题。但 PHP 8.2 还有相当大的存量,Pest 3 仍在维护(v3.8.6 是当前 3.x 最新版),用它一点都不过时。
总结
Pest 不是 PHPUnit 的替代品,它是 PHPUnit 的皮。它没造轮子,只是把 PHPUnit 的 API 用更现代的写法重新包了一层 —— 加上架构测试这个真正杀手锏。
如果你受够了 PHPUnit 的样板代码,试试 Pest 3,你会发现写测试这件事突然不那么痛苦了。
原文标题: [pestphp/pest]Pest 3:让写 PHP 测试不再痛苦
原文地址: https://phpreturn.com/index/a6a218d8be8289.html
原文平台: PHP武器库
版权声明: 本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。