Laravel 测试最佳实践实战:Coolify 开源 PaaS 仓库中的 6 大核心要点
【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify
导读:本文以 Coolify 开源仓库中《Laravel Best Practices》技能文档的 testing.md 规则 为主线,结合 Coolify 真实测试代码、模型事件钩子与工厂实现,系统讲解 Laravel/Pest 测试的 6 条黄金实践。读完你将掌握:如何选择合适的数据库刷新策略、如何写出自文档化且断言清晰的用例、如何正确处理模型事件与异常上报的测试边界,以及如何用recycle()优雅复用关联实例——每条规则都附有仓库内可对照的源码证据。
一、先看项目测试基建:Coolify 的测试骨架
Coolify 使用Pest(基于 PHPUnit)作为测试框架。从仓库结构看,测试分为 tests/Feature(约 500+ 个功能测试)、tests/Unit、v4(旧版回归测试)以及Browser(Dusk 浏览器测试)等目录,并通过 tests/Pest.php 将项目自定义的TestCase绑定到对应目录:
uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser', 'v5/Browser');而 tests/TestCase.php 只是 Laravel 基类的最小封装。值得注意的细节藏在全局beforeEach钩子里(tests/Pest.php):
beforeEach(function () { Once::flush(); // 清理 Laravel Once 记忆化缓存,保证每次测试拿到新鲜数据 Server::flushIdentityMap(); // 清理 Server 模型的“身份映射”缓存 config(['broadcasting.default' => 'null']); // 本地无法解析 realtime 主机时禁用广播 });这提示了 Coolify 测试体系的一个重要特点:缓存与单例状态需要逐用例清理。这也正是本文多条最佳实践要解决的问题——测试的坑大多来自“共享状态”和“框架魔法被破坏”。
运行测试使用 Laravel 标准命令即可:php artisan test(或./vendor/bin/pest),配合--filter、--testsuite等参数做定向执行。
二、用LazilyRefreshDatabase取代RefreshDatabase
规则原文解析
RefreshDatabase的语义是:每个进程只跑一次 migration,之后每个测试用“事务回滚”隔离(每个测试前开启事务,结束后回滚,因此无需反复建表)。但它仍要为每个测试进程执行一次全量迁移。
LazilyRefreshDatabase更进一步:若数据库 schema 已经是最新的,连这“第一次迁移”都会跳过。在长跑测试、多套环境、反复迭代的 CI 或本地开发中,这能省下可观的建表耗时。
仓库现状对照
在 Coolify 的功能测试里目前仍大量使用显式的RefreshDatabase。例如 tests/Feature/ActivityMonitorCrossTeamTest.php:
use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class);从代码结构可以推断,Coolify 的 Feature 测试绝大多数共享同一套 schema 且迁移文件繁多(database/migrations 下有大量增量迁移),这恰恰是LazilyRefreshDatabase的适用场景:首轮测试跑完迁移后,后续用例若 schema 未变即可跳过迁移直接进入事务回滚循环。
最佳实践写法
// tests/Feature/SomeTest.php use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; uses(LazilyRefreshDatabase::class);注意事项:LazilyRefreshDatabase依赖“schema 是否已是最新”的判断,因此如果你的测试目标是验证迁移本身(例如给新迁移写回放测试),或数据库被外部修改过,仍应使用RefreshDatabase甚至migrate:fresh来保证确定性。
三、用模型断言替代裸数据库断言
规则原文解析
错误写法是直接查表:
$this->assertDatabaseHas('users', ['id' => $user->id]);正确写法是断言“模型确实存在于数据库”:
$this->assertModelExists($user);模型断言的优势在于:表达力更强、类型安全(参数是模型实例而非字符串+数组)、失败信息更清晰(能直接显示模型主键与表名)。反向场景使用assertModelMissing($user)断言某模型已被删除。
仓库实证
tests/Feature/CleanupUnverifiedUsersCommandTest.php 正是这条规则的教科书式落地:先批量创建未验证用户,执行“清理未验证用户”命令后,用assertModelMissing验证删除、用assertModelExists验证保留:
$user = User::factory()->unverified()->create([...]); // 执行清理命令后…… $this->assertModelMissing($user); // 未验证用户被删除 $this->assertModelExists($kept); // 已验证/符合条件的用户仍存在另外,当表结构复杂、外键关联多时,裸assertDatabaseHas极易写错列名;而模型断言把“表/主键”的映射收敛在模型层,后续重构表名或主键时,测试无需跟着改。
四、用 Factory States 与 Sequences 让数据自文档化
规则原文解析
具名状态(Named States)让测试意图“自我描述”,序列(Sequences)则消除重复的手工造数据代码:
// 错误:魔法字符串散落,意图不明 User::factory()->create(['email_verified_at' => null]); // 正确:语义一目了然 User::factory()->unverified()->create();仓库实证
Coolify 的 UserFactory 已经内置了文档中所说的unverified()状态:
public function unverified(): static { return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); }默认 definition(UserFactory.php)生成email_verified_at => now()的已验证用户,unverified()用state()叠加覆写,测试里便无需再关心“为什么要把验证时间设为 null”。同样,database/factories/TeamFactory.php 也通过state()定制不同团队形态,说明 Coolify 遵循“默认状态 + 具名状态叠加”的工厂设计模式。
Sequence 的泛化用法(Laravel 框架级 API,Coolify 多环境/多数据库场景可同理套用):
Environment::factory() ->count(3) ->sequence( ['name' => 'production'], ['name' => 'staging'], ['name' => 'development'], ) ->create();把“数据长什么样”与“为什么要这样”解耦:状态名即注释,序列即批量化约定。
五、用Exceptions::fake()断言异常上报
规则原文解析
很多人习惯用withoutExceptionHandling()让异常直接抛出、中断请求,但它的副作用是把整个请求流程打断,你既看不到 HTTP 层响应,也无法区分“框架吞掉的异常”与“真正上报给日志/Sentry 的异常”。
正确姿势是用Exceptions::fake():请求照常完成,同时在测试中捕获并断言“哪个异常被上报了”:
use Illuminate\Support\Facades\Exceptions; Exceptions::fake(); $response = $this->get('/some/resource'); Exceptions::assertReported(SomeDomainException::class); // 异常确实被报告 $response->assertStatus(422); // 请求仍正常走完流程结合仓库理解“上报”语义
Coolify 在 app/Exceptions 目录中定义了自定义异常体系,例如NonReportableException(不应上报的“业务噪音”异常)等,并由 app/Exceptions/Handler.php 统一接管渲染与上报。这意味着测试异常路径时,核心问题是验证两件事:
- 异常是否被正确“报告”(进入日志/监控管线);
- 用户侧 HTTP 响应是否仍按预期返回(例如 4xx 页面,而不是 500 崩溃)。
withoutExceptionHandling()会把以上两点混为一谈;Exceptions::fake()则把“上报行为”与“响应行为”拆开分别断言,配合assertReported/assertNotReported/assertNothingReported,尤其适合验证像NonReportableException这种“应被静默处理”的领域异常。此规则目前尚未在仓库测试中铺开,但正是仓库异常设计下最值得推广的断言手段。
六、工厂构建之后再调用Event::fake()
规则原文解析
Eloquent 的模型事件(model events)也是经由事件分发器派发的。Event::fake()会替换事件分发器,从而让creating、created、saving等钩子全部静默失效。
而模型工厂恰恰重度依赖模型事件——典型如creating钩子里生成 UUID。如果顺序写反:
// 错误:钩子被屏蔽,工厂产出“残缺模型” Event::fake(); $user = User::factory()->create(); // 正确:先让模型带着完整钩子落库,再屏蔽后续业务事件 $user = User::factory()->create(); Event::fake();仓库实证:为什么这条规则在 Coolify 是“硬约束”
Coolify 大量模型都依赖creating事件完成“落库前补全”。最典型的在基类 app/Models/BaseModel.php:
static::creating(function (Model $model) { // Generate a UUID if one isn't set if (! $model->uuid) { $model->uuid = new_public_id(); } });此外 app/Models/Application.php 在creating钩子里还会为 GitHub/GitLab/Bitbucket/Gitea 生成各 40 位随机 webhook secret;若在Application::factory()->create()之前调用了Event::fake(),这些 secret 将全部为 null,随后任何涉及 webhook 校验的用例都会莫名其妙失败——这正是规则原文所说“producing broken models”在真实业务里的具体表现。
因此凡是要“既造出合法模型,又断言后续业务事件”的测试,务必遵守固定顺序:先工厂落库 → 再Event::fake()→ 后触发并断言事件。顺带一提,Coolify 的事件都集中在 app/Events(如ApplicationConfigurationChanged、ProxyStatusChanged等),配合Event::assertDispatched()做行为验证非常顺手。
七、用recycle()共享关系实例
规则原文解析
嵌套工厂(如Ticket::factory()->for(Airline::factory()))会在每一层嵌套时新建一个父实例,导致“多个子记录各自关联着不同的父记录”——而业务语义上它们本应共享同一个父实体。recycle()把预先创建好的实例“回收复用”:
Ticket::factory() ->recycle(Airline::factory()->create()) ->create();recycle()也接受集合,从而让一整批子记录共享同一个(或同一组)父实例。
结合仓库的适配思路
Coolify 的工厂体系非常完整:database/factories 下内置了ApplicationFactory、EnvironmentFactory、ProjectFactory、ServerFactory、TeamFactory等。当你构造“同一项目/环境下多个应用”或“同一团队下多个服务器”的用例时,若直接嵌套工厂,可能得到若干各自独立的环境/团队,进而让后续按project、team过滤查询的断言产生偶发性错误。
此时应先创建并回收父实例:
$project = Project::factory()->create(); Application::factory()->count(3) ->recycle($project) // 三个应用共享同一个 Project ->create(); expect(Application::where('project_id', $project->id)->count())->toBe(3);(上述字段名依实际模型关系为准,核心是复用「已存在父模型」而非反复新建。)这样既保持了工厂语法的整洁,又精确控制了父子归属关系,避免“同一个概念实体被拆成多份”的隐性问题。
八、规则速查对照表
| 场景 | ❌ 反模式 | ✅ 最佳实践 | 仓库对应证据 |
|---|---|---|---|
| 数据库刷新 | 每进程全量迁移 | schema 最新时跳过迁移 | ActivityMonitorCrossTeamTest 用RefreshDatabase,可升级为LazilyRefreshDatabase |
| 行存在性断言 | assertDatabaseHas('users', [...]) | assertModelExists($user) | CleanupUnverifiedUsersCommandTest |
| 造未验证用户 | 手工覆盖email_verified_at | User::factory()->unverified() | UserFactory::unverified() |
| 异常上报断言 | withoutExceptionHandling()打断请求 | Exceptions::fake()+assertReported() | app/Exceptions 的异常体系设计 |
| 需要同时造模型与断言事件 | 先Event::fake()再建工厂 | 工厂先落库再Event::fake() | BaseModel 的 creating 钩子、Application 的 creating 钩子 |
| 多条子记录共享父实例 | 嵌套工厂各自新建父实例 | 预建父实例后用recycle()复用 | database/factories 的模型工厂族 |
九、进阶阅读与落地建议
以上规则本质上都在解决 Laravel 测试的同一组核心矛盾——框架魔法(事件、迁移、缓存、单例)在测试环境中的“保真度”。Coolify 仓库本身是极好的对照样本:它既有干净的工厂状态设计(可模仿),也保留了可继续优化的RefreshDatabase用法(见 tests/Feature 大量用例),更有Once::flush()、Server::flushIdentityMap()这类“缓存即状态”的清理示范。
建议的落地顺序:
- 从工厂状态与模型断言入手,投入产出比最高,改动局部、风险最低;
- 为涉及 webhook/UUID 的新用例严格遵循“先建工厂再
Event::fake()”的顺序,避免踩中 BaseModel 与 Application 事件钩子被屏蔽的坑; - 在批量、长跑的测试套件上逐步把
RefreshDatabase切换为LazilyRefreshDatabase,用 CI 时间收益验证效果; - 异常路径用例逐步迁移到
Exceptions::fake(),让“上报”与“响应”两种行为分别可断言。
更多仓库内参考:规则原文见 .cursor/skills/laravel-best-practices/rules/testing.md,全局测试挂载点见 tests/Pest.php,工厂定义集中在 database/factories,模型事件实现可在 app/Models 内搜索static::creating钩子逐一对照。
【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考