大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
测试
这一课你将能做到
- 用
php artisan test运行 Unit/Feature 测试,读懂 PHPUnit 类风格与 Pest 函数风格两种用例 - 用
RefreshDatabase+Factory 在隔离数据库中快速搭建任意场景 - 用框架 Fake 与 Mockery 模拟邮件、队列、事件、外部 HTTP,让测试快速确定
为什么要学
没有测试,每次改动都是裸奔:无法确认新功能没破坏老逻辑,重构只能手点页面。测试把「我以为能跑」变成「可重复证明能跑」,并在每次提交时自动执行。Laravel 测试底层是 PHPUnit(JUnit 的 PHP 同源思想),可选语法更轻快的 Pest,且每个子系统都有替身:不必真发邮件、真扣款。对应 Spring 的 JUnit 5+Mockito+MockMvc+@SpringBootTest。
先建立图像
▲ 慢/少
┌──────┐
│ Unit │ 纯逻辑:判分、价格计算
├──────┤
│Feature│ HTTP→路由→控制器→数据库(主力)
├──────┤
│ E2E │ Dusk 操作真实浏览器(少量)
└──────┘
▼ 快/多
| Laravel | Spring Boot |
|---|---|
tests/Feature | @SpringBootTest+MockMvc |
tests/Unit | JUnit+Mockito |
| Factory | Test Fixture |
RefreshDatabase | 事务回滚/H2 |
Http::fake/Mail::fake | Mockito/WireMock |
跟着做
步骤 1:环境与第一个测试
phpunit.xml 关键项:
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="QUEUE_CONNECTION" value="sync"/>
php artisan make:test OrderPayTest
php artisan make:test PriceCalculatorTest --unit
php artisan test --filter=OrderPayTest --stop-on-failure
步骤 2:写 Feature 测试
tests/Feature/OrderPayTest.php:
<?php
namespace Tests\Feature;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderPayTest extends TestCase
{
use RefreshDatabase;
public function test_user_sees_their_paid_order(): void
{
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id, 'status' => 'paid']);
$this->actingAs($user)->getJson("/api/orders/{$order->id}")
->assertOk()
->assertJsonPath('data.status', 'paid')
->assertJsonStructure(['data' => ['id', 'order_no', 'amount', 'status']]);
$this->assertDatabaseHas('orders', ['id' => $order->id, 'status' => 'paid']);
}
public function test_user_cannot_see_others_order(): void
{
$order = Order::factory()->create();
$this->actingAs(User::factory()->create())
->getJson("/api/orders/{$order->id}")
->assertForbidden();
}
}
常用断言:assertOk/assertNotFound/assertUnauthorized、assertJson、assertSee(Blade)、assertRedirect、assertValidationErrors。
步骤 3:用工厂造数
database/factories/OrderFactory.php:
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition(): array
{
return [
'order_no' => 'NO'.$this->faker->unique()->numerify('##########'),
'amount' => $this->faker->numberBetween(100, 99900),
'status' => 'pending',
'channel' => 'mock',
];
}
public function paid(): static
{
return $this->state(fn () => ['status' => 'paid', 'paid_at' => now()]);
}
}
Order::factory()->paid()->count(10)->create(['user_id' => $user->id]);
$user = User::factory()->has(Order::factory()->count(3)->paid())->create();
Order::factory()->make(); // make 只生成不入库,create 入库
RefreshDatabase 原理:首次在内存库跑全部迁移,之后每个用例包事务自动回滚。迁移必须兼容 SQLite;用了 MySQL 专有功能时 CI 改用 Testcontainers 起真实 MySQL。
步骤 4:Pest 风格
Starter Kit 新项目可能默认 Pest,同样的用例写成:
use function Pest\Laravel\{actingAs, getJson};
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
it('lets a user view their paid order', function () {
$order = Order::factory()->paid()->create();
actingAs($order->user)->getJson("/api/orders/{$order->id}")->assertOk();
});
expect(1 + 1)->toBe(2);
expect($order->amount)->toBe(19900);
it 是测试函数、expect 是断言,与 PHPUnit 用例可共存,底层仍是 PHPUnit。
步骤 5:Fake 与 Mock
框架 Fake 一行替换所有调用:
use Illuminate\Support\Facades\{Http, Mail, Queue, Event, Storage};
Http::fake(['api.sms.com/*' => Http::response(['ok' => true], 200)]);
Mail::fake(); // ...触发逻辑...
Mail::assertSent(OrderReceipt::class);
Queue::assertPushed(\App\Jobs\SendOrderReceipt::class);
Event::fake([\App\Events\OrderPaid::class]);
Event::assertDispatched(\App\Events\OrderPaid::class);
Storage::fake('s3');
控制自己的服务用 Mockery(等价 Mockito):
$this->mock(\App\Contracts\PayGateway::class, function ($mock) {
$mock->shouldReceive('charge')->once()->with(19900)->andReturn(['trade_no' => 'T-1']);
});
$this->postJson('/api/orders', ['amount' => 19900])->assertOk();
替身经容器注入才生效——代码里若 new SomeClient() 则无法替换,这是依赖注入的又一个理由。
步骤 6:覆盖率与 CI
php artisan test --coverage # 需 Xdebug/PCOV
php artisan test --coverage --min=80 # 覆盖率门禁
php artisan test --parallel
CI 流程固定:composer install → 准备数据库 → php artisan test,失败即阻断合并。
讲清楚要点
- Feature 优先:多数 bug 在「胶水层」(路由、验证、权限、序列化),只有 Feature 测得到;纯计算下沉 Unit。
- 一个用例只验一件事:方法名用行为句描述,场景复杂拆多个方法。
- Factory 是默认世界:用例只写相关字段,其余工厂补齐,抗 schema 变化。
- 不 Mock 你不拥有的东西:优先 Fake 门面与自有接口;第三方打在
Http::fake协议层。 - 测试要快:内存库、少 seed、并行;慢测试会被团队跳过。
- 测试配置与生产尽量像:关键路径在 CI 用真实 MySQL、真实队列验证。
你可能会懵的地方
- Unit 和 Feature 怎么分?
碰数据库、认证、HTTP 的放 Feature;
new个类直接调的放 Unit。拿不准先写 Feature。 - RefreshDatabase 会清开发库吗? 不会,只作用于测试连接;但千万别把测试连接指向开发库。
- assertJson 明明对却失败?
子集匹配但数组顺序、类型("1" vs 1)要一致;精确断言用
assertJsonPath。 - Mock 了但没用到?
代码必须经容器/构造注入拿依赖;直接
new、静态调用都绕开替换。 - Pest 要专门学吗? 阅读门槛很低,写惯 PHPUnit 也够用,二者团队统一即可。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 用例间靠执行顺序共享数据 | 顺序一变全挂 | RefreshDatabase+工厂 |
| 测试真调支付短信 | 慢贵且脏 | Http::fake/Mock |
| 未认证却断言 200 | 实际 401 | actingAs |
| 一个测试断言十几件事 | 失败难定位 | 拆单用例 |
| Mock 第三方 SDK 内部方法 | SDK 升级假通过 | Http::fake 打协议层 |
| 代码里 new 客户端 | 无法替换 | 接口+容器绑定 |
| 只断言 200 | 业务错照上线 | 断言内容/落库/副作用 |
卡点急救
- 全部报 table not found:检查 RefreshDatabase 是否引入、新迁移有无语法错误。
- 找不到 Factory:自定义模型目录时用
newFactory()指定;确认模型use HasFactory。 - CI 与本地结果不一致:多为 SQLite/MySQL 差异(JSON、排序、布尔),CI 加 MySQL 矩阵。
面试常问
-
项目里怎么写测试? Feature 为主:RefreshDatabase 隔离、工厂造场景,断言覆盖状态码、响应与落库,Mail/Queue/Event fake 与 Http::fake 隔离副作用;CI 全量+覆盖率门禁。答出「隔离、替身、断言副作用」即合格。
-
Mock 和 Fake 的区别? Mock 按期望验证交互(次数、参数);Fake 是可用替身(Mail::fake 存集合供断言),成本更低。能 Fake 优先 Fake,自有接口才 Mock。
-
如何保证用例互不影响? 事务回滚+内存库、phpunit.xml 固定环境、不依赖顺序、Storage::fake;时间逻辑用
$this->travel(5)->days()。
自测三问
- 验证「下单后发邮件、订单落库、触发积分事件」,你用哪种测试、哪些 Fake、什么断言?
- Service 构造注入 PayGateway,如何让它返回固定结果?代码写成
new PayGateway()会怎样? - 套件要跑 10 分钟,从哪几方面优化?
小结一句话
Feature 守住主干、工厂与 RefreshDatabase 隔离场景、Fake 与 Mock 切断依赖,测试又快又可信。