☰
为什么Laravel12的测试框架比CakePHP5更完善
2026/10/3 4:19:50 网站建设 项目流程

前言

"换框架之后测试写不动了"——这是从 Laravel 转到 CakePHP,或者反过来做技术选型时最常听到的一句话。症状很具体:在 Laravel 里写一个接口测试,$this->get('/api/posts')->assertOk()->assertJsonPath('data.0.title', 'foo')两行搞定;换到 CakePHP 里,同样的场景要先声明 fixture、再确认 fixture 策略、再分别断言状态码和响应体,代码量翻好几倍。

这件事的根源不在"谁代码写得好",而在于两个框架对"测试支持"的定位完全不同:Laravel 把测试当成框架的一等公民,内置了一整套测试专用 API(假件、时间旅行、数据库断言、并行执行器);CakePHP 走的是"薄框架 + 标准 PHPUnit"路线,测试相关的东西集中在Cake\TestSuite命名空间里,风格更接近原生 PHPUnit,很多能力要自己搭。

本文按 Laravel 12(2025 年 2 月发布,要求 PHP 8.2+)与 CakePHP 5.x(要求 PHP 8.1+)对照,逐项拆开看差距在哪、为什么会有这个差距,以及"更完善"这个说法在什么条件下成立。

一、定位差异:测试 API 是"框架的一部分"还是"PHPUnit 的一层皮"

先看两边的测试基类和启动方式,这是所有差异的源头。

Laravel 12的测试继承自框架自己的基类:

<?php // tests/TestCase.php(Laravel 11 起骨架自带,不再有 CreatesApplication trait) declare(strict_types=1); namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { // }

这个BaseTestCase里塞进了大量 Laravel 专有方法:get()/post()等 HTTP 测试方法、assertSee()、assertJsonPath()、assertDatabaseHas()、actingAs()、withoutMiddleware()、travel()、freezeTime()、artisan()。也就是说,HTTP 测试、数据库断言、时间控制是被 Laravel 直接做进基类的。

CakePHP 5的测试同样有自己的基类,但思路是"给 PHPUnit 补一层适配器":

<?php // tests/TestCase/ArticlesControllerTest.php declare(strict_types=1); namespace App\Test\TestCase; use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; class ArticlesControllerTest extends TestCase { use IntegrationTestTrait; protected array $fixtures = ['app.Articles']; public function testIndex(): void { $this->get('/articles'); $this->assertResponseOk(); $this->assertResponseContains('Hello'); } }

注意IntegrationTestTrait是一个 trait,要手动use进来,而 Laravel 的 HTTP 测试能力是基类自带的。这个差别决定了"开箱体验":Laravel 新建测试文件就能用全部能力,CakePHP 要先知道哪个能力在哪个 trait 里。

二、能力对照:差距集中在四块

下面这张表是本文的核心。左边是能力维度,中间两列是两边的情况。

维度Laravel 12CakePHP 5
测试运行入口php artisan test,支持--parallel并行直接vendor/bin/phpunit,无官方并行命令
HTTP 测试基类自带get()/post()+ 链式断言IntegrationTestTrait提供get()+ 独立断言方法
数据准备模型工厂(Factory)+RefreshDatabaseFixture PHP 类 + Fixture 策略
外部依赖假件Mail::fake()、Queue::fake()、Event::fake()、Storage::fake()、Http::fake()、Notification::fake()、Bus::fake()无框架级假件,需自行 mock 或用扩展包
时间控制$this->travel(5)->days()、$this->freezeTime()由 Chronos 时间库提供测试时钟
断言风格assertOk()、assertJsonPath()、assertDatabaseHas()、assertInvalid()assertResponseOk()、assertResponseContains()、assertRedirect()
脚手架php artisan make:test/make:featurebin/cake bake test
默认测试运行器PHPUnit 11(Pest 为官方支持的第一方选项)PHPUnit 10/11,无第一方替代品

差距最大的是假件(fake)这一块。举个最典型的场景:一个注册接口,注册成功后发邮件、推队列、派事件。Laravel 里这样验证:

<?php declare(strict_types=1); use App\Mail\WelcomeMail; use App\Models\User; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; use Illuminate\Foundation\Testing\RefreshDatabase; class RegisterTest extends TestCase { use RefreshDatabase; public function test_register_sends_mail_and_queues_job(): void { Mail::fake(); Queue::fake(); $this->post('/register', [ 'name' => '张三', 'email' => 'zhangsan@example.com', 'password' => 'secret-1234', ])->assertRedirect('/home'); Mail::assertQueued(WelcomeMail::class); // 断言邮件进了队列 Queue::assertPushed(SendWelcomeJob::class); // 断言任务被派发 $this->assertDatabaseHas('users', ['email' => 'zhangsan@example.com']); } }

这段代码没有真实发邮件、没有真实推队列、没有真实连 SMTP,但仍然验证了"邮件和任务都发了"。Laravel 的 fake 是把容器里的绑定换成一个记录器,被测代码完全无感知。

CakePHP 5 里要做同样的事,路径是:用Queue插件(如cakephp/queue)时它可能自带 mock 支持,但邮件层面通常要么用Mailer::getTransport()换掉 transport,要么手写getMockBuilder()断言方法调用次数,要么在测试里直接不验证"发了什么",只验证"数据库状态对不对"。这不是不能做,而是每个项目都会重新发明一遍,且不同人的实现风格不一致。

三、为什么会这样:设计哲学与生态规模

差距不是偶然的,有三条结构性原因。

第一,Laravel 的"Facade(门面)+ 服务容器"让假件实现成本极低。Mail::fake()本质就是往容器里换一个MailFake单例,因为业务代码写的是Mail::send()这种静态门面调用,解析时机固定在运行时,所以测试期替换轻而易举。CakePHP 的依赖注入更"显式"——服务通常通过构造函数注入或LocatorAwareTrait获取——显式注入更干净,但要在测试里整体替换就得逐处设置。

第二,Laravel 官方把测试工具当成框架文档的一部分在维护。测试章节和路由、Eloquent 章节地位相同,每个版本都会更新;Pest 也由 Laravel 团队成员主导,可以视为第一方。CakePHP 的bake test脚手架很成熟,但测试基建本身多年没有大改,IntegrationTestTrait这套 API 从 3.x 延续至今。

第三,数据准备思路不同。Laravel 用模型工厂:User::factory()->count(3)->create(),数据在测试文件里就地生成,可读性高,也天然支持状态(->state())和关系。CakePHP 用Fixture:数据集中在tests/Fixture/*Fixture.php里声明,跟数据库 schema 绑得更紧,复用好但"这个测试到底需要什么数据"要从 fixture 类里翻。

<?php // tests/Fixture/ArticlesFixture.php(CakePHP 5) declare(strict_types=1); namespace App\Test\Fixture; use Cake\TestSuite\Fixture\TestFixture; class ArticlesFixture extends TestFixture { // CakePHP 4.3 起 $fields 可从数据表反射得到,通常可省略 public array $records = [ ['id' => 1, 'title' => 'Hello', 'published' => 1], ['id' => 2, 'title' => 'World', 'published' => 0], ]; }

对比 Laravel 侧同一件事:

<?php // 就地生成,只造这个测试需要的数据 Article::factory()->count(2)->sequence( ['title' => 'Hello', 'published' => true], ['title' => 'World', 'published' => false], )->create();

Fixture 的方式在"测试数据是业务字典类数据(角色、状态码表)"时更好——一次定义处处复用;工厂的方式在"每个测试要不同的数据形状"时更好。两者不是简单的高下之分。

四、CakePHP 并不弱的地方

只说差距不公平,这几点 CakePHP 5 反而更清爽:


  • ORM 断言更贴近 SQL:CakePHP 的TestCase提供getMockForModel(),可以在不碰数据库的前提下把模型方法整体替换掉,单元测试跑得飞快。

  • Fixture 策略可切换:通过FixtureStrategyInterface的实现(事务策略 / 清表策略)决定每个测试之间怎么清理数据,大型项目里比 Laravel 的RefreshDatabase/LazilyRefreshDatabase二选一更灵活。

  • 控制台测试直观:ConsoleIntegrationTestTrait的exec('bake model Articles')+assertOutputContains()写法,比 Laravel 的artisan()链式 API 更接近"我就是在跑一条命令"的直觉。

  • 没有隐式全局状态:Laravel 的Cache::fake()之类如果忘了重置,偶尔会污染后续测试;CakePHP 因为不搞全局假件,反而没这类问题。


五、实战:同一个业务在两个框架里的测试写法

需求:POST /articles创建文章,校验标题必填,成功后返回 201。

Laravel 12:

<?php declare(strict_types=1); // tests/Feature/CreateArticleTest.php // 运行:php artisan test --filter=CreateArticleTest namespace Tests\Feature; use App\Models\Article; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class CreateArticleTest extends TestCase { use RefreshDatabase; public function test_创建成功返回201(): void { $response = $this->postJson('/api/articles', ['title' => '新文章']); $response->assertCreated() ->assertJsonPath('data.title', '新文章'); $this->assertDatabaseHas('articles', ['title' => '新文章']); } public function test_标题缺失返回422(): void { $this->postJson('/api/articles', []) ->assertStatus(422) ->assertJsonValidationErrors(['title']); $this->assertDatabaseCount('articles', 0); } }

CakePHP 5:

<?php declare(strict_types=1); // tests/TestCase/Controller/ArticlesControllerTest.php // 运行:vendor/bin/phpunit tests/TestCase/Controller/ArticlesControllerTest.php namespace App\Test\TestCase\Controller; use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; class ArticlesControllerTest extends TestCase { use IntegrationTestTrait; protected array $fixtures = ['app.Articles']; public function testCreateSuccess(): void { $this->post('/articles.json', ['title' => '新文章']); $this->assertResponseCode(201); $this->assertResponseContains('新文章'); } public function testCreateMissingTitle(): void { $this->post('/articles.json', []); $this->assertResponseCode(422); } }

差异一目了然:Laravel 侧用assertJsonPath()直接按路径取 JSON 值、用assertDatabaseHas()一句断言数据库,还有assertCreated()和assertJsonValidationErrors()这种语义化断言;CakePHP 侧需要靠assertResponseContains()做字符串包含判断,想精确断言 JSON 结构得自己json_decode($this->_response->getBody(), true)再assertSame()。这类"要自己搭"的细节累加起来,就是"更完善"这个印象的来源。

同时要注意:CakePHP 的assertResponseCode()这类断言方法返回的是void,不能链式调用,所以上面只能一行一个断言;Laravel 的断言方法普遍返回$this,可以串起来。

常见坑点

1. 在 Laravel 里忘了用数据库隔离 trait,测试互相污染

// ❌ 第一个测试插了数据,第二个测试的 assertDatabaseCount 就不是 0 了 class FooTest extends TestCase { public function test_a(): void { Article::create(['title' => 'a']); $this->assertDatabaseCount('articles', 1); } public function test_b(): void { $this->assertDatabaseCount('articles', 0); } // 随机失败 }
// ✅ 加上隔离 trait;全量迁移慢时用 LazilyRefreshDatabase,只在真正碰库的测试里迁移 use Illuminate\Foundation\Testing\RefreshDatabase; class FooTest extends TestCase { use RefreshDatabase; }

2. 在 CakePHP 里改了 fixture 文件却没重新构建 schema

// ❌ 只改了 $records 里的字段名,字段本身没加到表里,测试报列不存在 public array $records = [['id' => 1, 'slug' => 'x']];
# ✅ fixture 表结构来自数据库,改字段前先跑一次迁移 bin/cake migrations migrate

3. Laravel 的Mail::fake()写在被测代码之后

// ❌ fake 必须在触发行为之前调用,否则邮件已经真发出去了 $this->post('/register', $payload); Mail::fake(); Mail::assertQueued(WelcomeMail::class);
// ✅ 先换掉实现,再触发行为 Mail::fake(); $this->post('/register', $payload); Mail::assertQueued(WelcomeMail::class);

4. 用withoutMiddleware()图省事,把鉴权一起绕过

// ❌ 鉴权中间件被移除,一个未登录用户也能"通过"测试,测试永远绿 $this->withoutMiddleware(); $this->post('/api/articles', ['title' => 'x'])->assertCreated();
// ✅ 用 actingAs 真实登录,只绕过与测试目标无关的中间件 $this->actingAs(User::factory()->create()) ->postJson('/api/articles', ['title' => 'x']) ->assertCreated();

5. CakePHP 测试里访问受保护属性

// ❌ _response 是 protected,直接读会报错 $this->assertEquals(200, $this->_response->getStatusCode());
// ✅ 用 trait 提供的公开断言方法 $this->assertResponseCode(200);

6. Laravel 测试里php artisan test与直接phpunit行为不一致

# ❌ 直接跑 phpunit 可能不加载 .env.testing 与 phpunit.xml 里注入的环境变量, # 结果跑在真实数据库上 vendor/bin/phpunit
# ✅ 统一从 artisan 入口跑,保证环境变量按 phpunit.xml 注入 php artisan test

7. 在测试里断言"时间"却不冻结时间

// ❌ 断言里写死日期,明天就红 $this->assertSame('2026-09-29', $post->created_at->toDateString());
// ✅ 冻结时间,让断言与真实时钟解耦 $this->freezeTime(); // 或 $this->travelTo(Carbon::parse('2026-09-29 10:00:00')); $post = Post::factory()->create(); $this->assertSame('2026-09-29', $post->created_at->toDateString());

8. 用 fixture 当作"万能数据源",测试之间的隐式耦合

// ❌ 依赖 Fixture 里恰好有 id=1 的记录,别人删掉一条就全线崩 public function testShow(): void { $this->get('/articles/1'); $this->assertResponseOk(); }
// ✅ 测试自己造自己需要的数据,或至少断言前先确认存在 public function testShow(): void { $this->get('/articles/1'); $this->assertResponseCode(404); } // 不依赖具体 id

总结

结论说明
差距主要在"开箱即用"层面Laravel 12 把 HTTP 测试、数据库断言、假件、时间控制全做进基类;CakePHP 5 保持薄适配层,能力靠 trait 和第三方补齐
数据准备思路是根本分歧Laravel 用工厂就地造数据;CakePHP 用 fixture 集中声明 + 可切换策略
"更完善"取决于衡量标准论 API 覆盖面和写法密度,Laravel 12 明显领先;论贴近原生 PHPUnit、减少隐式全局状态,CakePHP 5 更克制
选型不该只看测试如果团队重度依赖队列、邮件、外呼 HTTP 的隔离验证,Laravel 的 fake 体系能省掉大量自研成本;如果项目本来就是薄框架 + 强 ORM 风格,CakePHP 的测试方式并不会成为瓶颈

一句话结论:Laravel 12 的测试框架确实更完善,但它完善的是"用更少的代码表达更多的断言"这件事——假件、链式断言、时间旅行都是为这个目标服务的;CakePHP 5 的测试基建并不残缺,只是它选择了把自由度留给开发者。做技术选型时,把"我们的测试里有多少外部依赖需要隔离"这个问题回答清楚,比争论哪个框架的测试 API 更多更有意义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询