[pestphp/pest]Pest 3:让写 PHP 测试不再痛苦

2026-06-04 奥古斯宏 #PHP #测试 #Pest #PHPUnit
PHPUnit 的样板代码让很多人宁可裸奔也不写测试。Pest 3 用一个 it() 函数把这些啰嗦全吃了,还顺手送了一个能扫整个项目的架构测试。

引入段

写 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 写法:

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

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,输出长这样:

Pest 3 测试运行输出(含架构测试失败)

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

这张截图里既有 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",非常自然。

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

架构测试:真正的杀手锏

前面那些语法糖都是锦上添花,架构测试(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)",交给测试就行。这就是架构测试的价值 —— 把团队争议变成可执行的规则。

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

新的配置 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() 之后,输出变成一行点号 + 总结:

Pest 3 紧凑模式输出

CI 日志里这种格式比整页 PASS 列表省事很多。

单测级 teardown:after()

Pest 3 给单个测试或 describe 加了 after():

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

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 的输出:

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

Pest 3 Mutation Testing 输出

这里 10 个变异全被测试抓出来了(Score: 100.00%),每个变异对应源码里的一处改动,比如 PlusToMinus(把加号改成减号)、IfNegated(if 条件取反)。如果有任何变异没被测试抓出来,Score 就会跌下去,这时候该补测试用例了。

Laravel 模块化项目的坑

如果用 nwidart/laravel-modules 把测试放在 Modules/X/Tests/ 而不是默认的 tests/Featuretests/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/ 目录下应用配置。

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

升级 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/frameworkpestphp/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');

踩坑清单(都是真的):

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

  • topthink/think-testing 别装,装上也跑不起来(7 年没更新)
  • ThinkPHP 没有 RefreshDatabase 这种东西,数据库测试要自己写事务回滚或用独立的测试库
  • App::initialize() 会去读 .envconfig/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,你会发现写测试这件事突然不那么痛苦了。

最近浏览
IP用户:57.141.*.*
1 小时前 Generic Bot
累计浏览次数:2
评论
点击登录
phpreturn,PHP武器库,专注PHP领域的项目和资讯,收录和介绍PHP相关项目。

本站所有权利归 phpreturn.com 所有

举报/反馈/投稿邮箱:phpreturn@ulthon.com

鲁ICP备19027671号-2