Pest 测试纪律
功能未经测试就上线,或者使用过度模拟、断言实现细节的“测试”来覆盖,都会在生产环境中悄无声息地崩溃,并在每次重构中腐烂。本技能强制你编写 Pest 3/4 测试,以证明行为,覆盖异常路径和授权,并且能够在重构中存活。
陷阱
两种代价高昂的失败模式:
- 没有测试 / 仅快乐路径测试。 功能在演示中运行正常,但在生产环境中,访客命中了端点、某个租户读取了另一个租户的数据、验证漏洞让垃圾数据进入、或者 404 变成了 500。授权漏洞(从未断言的
403/404路径)是数据泄露流入生产的途径。 - 脆弱、过度模拟的测试。 你模拟自己的
UserRepository/服务,使用shouldReceive('save')->once(),所以即使真实查询是错误的,测试也会通过,并且一旦有人重命名方法,它就会崩溃。套件是绿色的,给你虚假的信心,并在下次重构中因挫败而被删除——让你回到模式 1。
无论哪种方式,解决方法都是相同的纪律:伪造你不拥有的边界,执行你拥有的代码,并断言可观察的结果(数据库行、HTTP 状态、分发的工作/邮件)——包括失败和授权结果。
规则
- 与功能一起编写测试,而不是“以后”。 代码变更(控制器/工作/命令/策略)在拥有相应的 Pest 测试之前是不完整的。永远不要在没有相应测试断言其行为的情况下报告功能完成。
- 使用 Pest 习惯用法。 使用
it('...')或test('...')定义用例;使用流畅的expect(...)进行断言;使用beforeEach()共享设置。不要编写 PHPUnitclass FooTest extends TestCase,除非该文件已经使用该风格。 - 选择正确的类型。 HTTP/端到端行为 → 功能测试(启动框架,通过
$this->get/post/...命中路由)。没有框架/数据库的纯逻辑 → 单元测试。在有疑问时,默认使用功能测试——它能捕获更多问题。 - 使用 Trait 重置数据库,通过
uses()。 在tests/Pest.php中为整个Feature目录应用RefreshDatabase(事务包装)或LazilyRefreshDatabase(仅在测试接触数据库时迁移),或者在文件顶部使用uses(RefreshDatabase::class)。绝不要让数据库测试依赖遗留状态。 - 使用工厂构建数据,而不是手写插入。 使用
User::factory()->create()/->make()和关系工厂。在测试中手写DB::table()->insert([...])或new Model([...])->save()很脆弱,会跳过类型转换/默认值——禁止使用。 - 使用数据集去重,而不是复制粘贴。 当相同的断言需要对许多输入(有效/无效载荷、角色矩阵)运行时,请使用
->with([...])(内联或在tests/Datasets/中命名的数据集),而不是 N 个几乎相同的测试。 - 伪造你不拥有的边界。 对于任何外部或异步的内容,在操作之前使用 Facade Fake:
Http::fake()、Queue::fake()、Bus::fake()、Mail::fake()、Notification::fake()、Event::fake()、Storage::fake()。对于时间,使用$this->travel(...)、$this->travelTo(...)或$this->freezeTime()——永远不要针对未冻结的now()进行断言。 - 不要模拟你自己的模型/服务使其变得无意义。 避免在被测试的代码中使用
Mockery::mock(MyService::class)->shouldReceive(...)和Model::shouldReceive(...)。让真实代码针对测试数据库和伪造的边界运行。仅在你没有在练习的真正接缝处(例如,一个慢的第三方 SDK 包装器)模拟你自己的类。 - 断言结果,而不是内部实现。 优先使用
assertDatabaseHas/assertDatabaseMissing/assertModelExists、assertStatus/assertOk/assertCreated、assertJson/assertJsonPath、assertRedirect、Mail::assertSent、Notification::assertSentTo、Queue::assertPushed、Bus::assertDispatched、Storage::disk()->assertExists。不要将“方法 X 被调用了一次”作为行为的代理进行断言。 - 始终测试异常路径和授权。 每个端点/操作至少需要:快乐路径、验证/失败路径以及认证路径——访客 →
401/重定向、错误用户/角色 →403、缺失/外键资源 →404。对于任何安全敏感功能,添加一个显式的“另一个用户无法访问/修改此内容”的测试。对于任何涉及所有权或租户的内容,这是不可妥协的。 - 添加架构测试作为廉价的全局护栏。 保留一个
tests/Arch.php(或在Pest.php中的架构用例),其中包含适合的预设:arch()->preset()->php()(禁止die/var_dump/dd风格的调试输出和废弃的 PHP 函数)、arch()->preset()->security()(禁止eval、extract、unserialize、弱哈希/md5/sha1、不安全的随机性等)以及arch()->preset()->laravel()(强制执行 Laravel 约定——包括在配置之外不允许env())。添加项目不变量,如arch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller')。 - 每个测试一个行为,描述性名称。
it('阻止访客访问仪表板'),而不是it('工作正常')。如果测试名称需要“和”,请拆分它。 - 在 CI 中门控覆盖率。 运行
./vendor/bin/pest --coverage --min=NN强制执行最低值,以及./vendor/bin/pest --type-coverage(需要类型覆盖率插件)强制执行类型签名。将新的未测试行视为缺陷。 - 谨慎使用浏览器/E2E 测试(Pest 4)。 Pest 4 通过
visit('/')->...->assertSee(...)(基于 Playwright)提供浏览器测试。将其保留用于真正的全栈/JS 流程;不要将 HTTP 层断言重新实现为缓慢的浏览器测试。
好 vs 坏
// tests/Feature/UpdatePostTest.php
use App\Models\Post;
use App\Models\User;
// ❌ 过度模拟:测试了一个方法被调用,而不是发生了什么。
// 即使错误的帖子被更新或认证缺失,测试也会通过。
it('更新帖子', function () {
$service = Mockery::mock(App\Services\PostService::class);
$service->shouldReceive('update')->once()->andReturnTrue();
$this->app->instance(App\Services\PostService::class, $service);
$this->put('/posts/1', ['title' => '新标题'])->assertOk();
});
// ✅ 针对测试数据库运行真实代码;断言结果和授权。
use App\Models\Post;
use App\Models\User;
it('允许所有者更新他们的帖子', function () {
$owner = User::factory()->create();
$post = Post::factory()->for($owner)->create(['title' => '旧标题']);
$this->actingAs($owner)
->put("/posts/{$post->id}", ['title' => '新标题'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => '新标题']);
});
it('禁止非所有者更新帖子', function () {
$post = Post::factory()->create(['title' => '旧标题']);
$attacker = User::factory()->create();
$this->actingAs($attacker)
->put("/posts/{$post->id}", ['title' => '被黑'])
->assertForbidden(); // 403
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => '旧标题']);
});
it('阻止访客', function () {
$post = Post::factory()->create();
$this->put("/posts/{$post->id}", ['title' => '新标题'])
->assertRedirect('/login'); // 或者对于 API 使用 assertUnauthorized()
});
// ❌ 命中了真实网络(不稳定、慢、可能泄露密钥);没有断言任何有用的东西。
it('通知 slack', function () {
$resp = (new App\Services\Slack)->ping('部署成功');
expect($resp)->toBeArray();
});
// ✅ 伪造你不拥有的边界;断言你发送的请求。
use Illuminate\Support\Facades\Http;
it('向 slack 发送部署消息', function () {
Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
(new App\Services\Slack)->ping('部署成功');
Http::assertSent(fn ($request) =>
$request->url() === 'https://hooks.slack.com/services/T/B/x'
&& $request['text'] === '部署成功'
);
});
// ❌ 复制粘贴的几乎相同的验证测试。
it('拒绝空邮箱', function () {
$this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('拒绝错误邮箱', function () {
$this->post('/register', ['email' => '错误'])->assertSessionHasErrors('email');
});
// ✅ 一个测试,使用数据集进行表驱动。
it('拒绝无效的邮箱', function (string $email) {
$this->post('/register', ['email' => $email])
->assertSessionHasErrors('email');
})->with([
'空' => '',
'没有 @ 符号' => '错误',
'没有域名' => 'a@',
]);
// ✅ 伪造了异步 + 时间边界;断言分发和副作用。
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('在注册时排队提醒并欢迎用户', function () {
Queue::fake();
Mail::fake();
$this->freezeTime();
$this->post('/register', [
'name' => 'Ada', 'email' => '[email protected]', 'password' => 'secret-pass',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php — ✅ 廉价的全局护栏
arch()->preset()->php(); // 禁止 die/var_dump/dd 风格的调试输出 + 废弃的 PHP 函数
arch()->preset()->security(); // 禁止 eval, extract, unserialize, md5/sha1, 弱随机性等
arch()->preset()->laravel(); // 强制执行 Laravel 约定,包括配置之外不允许 env()
arch('控制器扩展基础控制器')
->expect('App\Http\Controllers')
->toExtend('App\Http\Controllers\Controller');
arch('模型位于正确的命名空间')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
如何验证
在声称一个功能及其测试完成之前,运行这些:
# 1. 整个测试套件必须为绿色。
./vendor/bin/pest
# 2. 迭代时只运行新/更改的文件。
./vendor/bin/pest tests/Feature/UpdatePostTest.php
# 3. 强制执行覆盖率下限(需要 Xdebug 或 PCOV)。
./vendor/bin/pest --coverage --min=80
# 4. 类型覆盖率(需要 pestphp/pest-plugin-type-coverage)。
./vendor/bin/pest --type-coverage --min=100
# 5. 风格保持整洁。
./vendor/bin/pint --test
然后审计你没有重新引入陷阱:
# 授权覆盖率:每个受保护的功能测试应该断言一个 401/403/404 路径。
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\((401|403|404)\)" tests/
# 过度模拟的味道:模拟你自己的 APP 类是一个危险信号——审查每一个命中。
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\(" tests/
# 手写插入而不是工厂——替换为 ->factory()。
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/
# 外部边界必须被伪造——确认代码调用外部的地方存在伪造。
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/
# 安全和 PHP 架构预设(廉价的全局护栏)必须存在于某处。
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/
在测试中断言,而不仅仅在代码中:对于每个新端点,确认存在 (a) 一个快乐路径结果断言(assertDatabaseHas/assertOk/assertJsonPath),(b) 至少一个失败/验证断言,以及 (c) 访客和错误用户的授权断言。如果任何一项缺失,功能就不算完成。
何时可以放宽规则
- 在真正的外部接缝处模拟你自己的代码。 一个围绕付费第三方 SDK(短信、支付网关)的薄包装器,你无法在 HTTP 层伪造,是合法的
Mockery目标——你是在隔离他们的依赖,而不是伪造正在测试的行为。 - 在没有数据库的情况下进行纯逻辑的单元测试。 值对象、格式化器和计算器不需要
RefreshDatabase和工厂——直接实例化它们并expect()结果;这样更快且正确。 - 使用
make()而非create()当测试永远不会持久化时——User::factory()->make()避免了对纯逻辑断言执行数据库写入。 - 在遗留代码上设置较低的覆盖率阈值。 设置一个现实的
--min并随时间提高它,而不是阻止所有工作;绝不要让“100% 或一无所获”成为交付零测试的借口。 - 对真正由 JS 驱动的流程进行浏览器测试(Livewire/Inertia/Vue 交互、文件选择器)——在那里,
visit()是正确的工具,而不是过度使用。
参考
- Pest — 编写测试、期望、钩子:https://pestphp.com/docs/writing-tests
- Pest — 数据集:https://pestphp.com/docs/datasets
- Pest — 架构测试和预设(
security、laravel、toExtend):https://pestphp.com/docs/arch-testing - Pest — 覆盖率和
--min:https://pestphp.com/docs/coverage - Pest — 类型覆盖率插件:https://pestphp.com/docs/type-coverage
- Pest v4 — 浏览器测试(
visit()、视觉回归):https://pestphp.com/docs/browser-testing - Laravel — HTTP 测试(状态/JSON/重定向断言):https://laravel.com/docs/testing
- Laravel — 数据库测试(
RefreshDatabase、工厂、assertDatabaseHas):https://laravel.com/docs/database-testing - Laravel — 模拟和 Facade Fake(
Http、Queue、Bus、Mail、Notification、Event、Storage、时间):https://laravel.com/docs/mocking