CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
测试
这一课你将能做到
用 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+MockMvctests/UnitJUnit+Mockito Factory Test Fixture RefreshDatabase事务回滚/H2 Http::fake/Mail::fakeMockito/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 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 切断依赖,测试又快又可信。
延伸