什么是 Pest
Pest 是构建在 PHPUnit 之上的测试框架,Laravel 11 之后的新项目默认采用。它保留了 PHPUnit 丰富的断言集合,同时用基于闭包的简洁语法来编写测试。 与 PHPUnit 的主要区别:| 视角 | Pest | PHPUnit |
|---|---|---|
| 测试定义 | test() / it() 闭包 | 用方法定义的类 |
| 断言 | Expectation API(expect()->toBe()) | $this->assert*() |
| 数据集 | dataset() / with() | @dataProvider |
| 钩子 | beforeEach() / afterEach() | setUp() / tearDown() |
| 嵌套 | 用 describe() 分组 | 用类分离 |
Pest 测试仍然通过 PHPUnit 执行,可以与既有的 PHPUnit 测试共存。用
php artisan test 或 vendor/bin/pest 运行。describe / it / test 的用法差异
test()
最简单的定义。测试名会作为说明使用。
test('用户可以用邮箱登录', function () {
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect('/dashboard');
});
it()
「it should…」这种英文文体,读起来接近自然语言。
it('会将未认证用户重定向到登录页', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});
describe()
将相关测试分组。可用于共享 beforeEach() 或在文件内进行逻辑整理。
describe('订单管理', function () {
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
it('可以获取订单列表', function () {
Order::factory(3)->for($this->user)->create();
$response = $this->get('/orders');
$response->assertOk()->assertJsonCount(3, 'data');
});
it('不能获取其他用户的订单', function () {
$other = User::factory()->create();
Order::factory()->for($other)->create();
$response = $this->get('/orders');
$response->assertOk()->assertJsonCount(0, 'data');
});
});
describe() 可以嵌套,但过深的嵌套会让测试难读,一般建议不超过 2 层。Expectation API
expect() 是 Pest 特有的断言语法,可以链式叠加条件。
基本断言
expect($value)->toBe(42); // 严格相等(===)
expect($value)->toEqual(['a' => 1]); // 宽松相等(==)
expect($value)->toBeTrue();
expect($value)->toBeFalse();
expect($value)->toBeNull();
expect($value)->not->toBeNull();
expect($string)->toContain('Laravel');
expect($array)->toHaveCount(3);
expect($array)->toHaveKey('email');
expect($array)->toContain('admin');
expect($number)->toBeGreaterThan(0);
expect($number)->toBeLessThanOrEqual(100);
针对模型的断言
expect($user)->toBeInstanceOf(User::class);
expect($user->email)->toMatchRegex('/^.+@.+\..+$/');
// 确认已保存到数据库
expect(User::where('email', '[email protected]')->exists())->toBeTrue();
and() 链
expect($response->status())->toBe(200)
->and($response->json('name'))->toBe('Laravel')
->and($response->json('version'))->toBeGreaterThan(12);
用 each() 校验数组每个元素
$users = User::factory(3)->create();
expect($users)->each(function ($user) {
$user->toBeInstanceOf(User::class)
->email->not->toBeNull();
});
使用数据集进行参数化测试
对同一逻辑使用多个输入进行测试时,使用dataset() 或行内 with()。
行内数据集
it('无效邮箱应产生校验错误', function (string $email) {
$response = $this->post('/register', ['email' => $email]);
$response->assertInvalid('email');
})->with([
'纯文本' => ['not-an-email'],
'缺少域名' => ['user@'],
'空字符串' => [''],
]);
命名数据集
在tests/Datasets 目录中定义数据集。
// tests/Datasets/InvalidEmails.php
dataset('invalid_emails', [
'plain' => ['not-an-email'],
'no-domain' => ['user@'],
'empty' => [''],
'spaces' => ['user @example.com'],
]);
it('无效邮箱应产生校验错误', function (string $email) {
$response = $this->post('/register', ['email' => $email]);
$response->assertInvalid('email');
})->with('invalid_emails');
使用闭包的动态数据集
it('不同套餐拥有不同的限制', function (string $plan, int $limit) {
$user = User::factory()->create(['plan' => $plan]);
expect($user->requestLimit())->toBe($limit);
})->with([
['free', 100],
['pro', 1000],
['enterprise', 10000],
]);
使用 Mockery 编写单元测试
服务的 Mock
use App\Services\PaymentGateway;
use Mockery\MockInterface;
test('调用了支付服务', function () {
$mock = $this->mock(PaymentGateway::class, function (MockInterface $mock) {
$mock->expects('charge')
->with(1000, 'cny')
->andReturn(['status' => 'succeeded']);
});
$result = app(PaymentGateway::class)->charge(1000, 'cny');
expect($result['status'])->toBe('succeeded');
});
Spy
Spy 与 Mock 不同,它在调用真实实现的同时记录调用。use App\Services\NotificationService;
test('确认通知服务被调用', function () {
$spy = $this->spy(NotificationService::class);
$this->post('/orders', ['amount' => 1000]);
$spy->shouldHaveReceived('send')->once()->with('order.created');
});
部分 Mock
只对部分方法进行 Mock,其余方法使用真实实现。use App\Services\ReportService;
use Mockery\MockInterface;
test('跳过报表写文件', function () {
$mock = $this->partialMock(ReportService::class, function (MockInterface $mock) {
$mock->expects('writeFile')->andReturnNull();
});
$result = $mock->generate();
expect($result)->not->toBeNull();
});
通过 HTTP Fake 测试外部 API 调用
使用Http::fake() 可以不发起真实 HTTP 请求而对响应打桩。
基础 Fake
use Illuminate\Support\Facades\Http;
test('从外部 API 获取用户信息', function () {
Http::fake([
'https://api.example.com/users/*' => Http::response([
'id' => 1,
'name' => 'Laravel User',
], 200),
]);
$result = app(UserApiClient::class)->find(1);
expect($result['name'])->toBe('Laravel User');
Http::assertSent(fn ($request) => $request->url() === 'https://api.example.com/users/1');
});
错误响应的测试
test('API 返回错误时抛出异常', function () {
Http::fake([
'api.example.com/*' => Http::response([], 503),
]);
expect(fn () => app(UserApiClient::class)->find(1))
->toThrow(\App\Exceptions\ApiUnavailableException::class);
});
模拟网络故障
use Illuminate\Http\Client\ConnectionException;
test('处理连接错误', function () {
Http::fake(fn () => throw new ConnectionException('Connection refused'));
$result = app(UserApiClient::class)->findWithFallback(1);
expect($result)->toBeNull();
});
事件、邮件、通知的 Fake
Event::fake()
use Illuminate\Support\Facades\Event;
use App\Events\OrderCreated;
test('创建订单时会派发事件', function () {
Event::fake();
$this->post('/orders', ['product_id' => 1, 'amount' => 1000]);
Event::assertDispatched(OrderCreated::class, function ($event) {
return $event->order->amount === 1000;
});
});
Event::fake([OrderCreated::class]);
Mail::fake()
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;
test('用户注册时发送欢迎邮件', function () {
Mail::fake();
$this->post('/register', [
'name' => 'Test User',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
Mail::assertSent(WelcomeEmail::class, function ($mail) {
return $mail->hasTo('[email protected]');
});
});
Notification::fake()
use Illuminate\Support\Facades\Notification;
use App\Notifications\OrderShipped;
test('发货时发送通知', function () {
Notification::fake();
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
$this->post("/orders/{$order->id}/ship");
Notification::assertSentTo($user, OrderShipped::class, function ($notification) use ($order) {
return $notification->order->id === $order->id;
});
});
Artisan 命令的测试
use Illuminate\Support\Facades\Artisan;
test('清理无效数据的命令能正常工作', function () {
$expired = Order::factory()->create(['expires_at' => now()->subDay()]);
$valid = Order::factory()->create(['expires_at' => now()->addDay()]);
$this->artisan('orders:cleanup')
->assertSuccessful()
->expectsOutput('Cleaned up 1 expired order(s).');
expect(Order::find($expired->id))->toBeNull();
expect(Order::find($valid->id))->not->toBeNull();
});
交互式命令的测试
test('交互式命令确认后执行处理', function () {
$this->artisan('reports:generate')
->expectsQuestion('要执行吗?', 'yes')
->expectsOutput('报表已生成。')
->assertExitCode(0);
});
RefreshDatabase 与 LazilyRefreshDatabase
RefreshDatabase
在每个测试后回滚数据库以保持干净状态。测试套件启动时会执行迁移。
// tests/Pest.php
uses(RefreshDatabase::class)->in('Feature');
use Illuminate\Foundation\Testing\RefreshDatabase;
test('可以创建用户', function () {
$user = User::factory()->create(['name' => 'Laravel']);
expect(User::count())->toBe(1);
expect($user->name)->toBe('Laravel');
});
LazilyRefreshDatabase
RefreshDatabase 会对所有测试都检查迁移,而 LazilyRefreshDatabase 会延迟到实际会修改数据库的测试运行时才执行迁移。当有很多不接触数据库的测试时可加速。
// tests/Pest.php
uses(LazilyRefreshDatabase::class)->in('Feature');
多数项目选择
RefreshDatabase 更安全。当测试规模庞大且大量测试不接触数据库时,可以考虑 LazilyRefreshDatabase。| 视角 | RefreshDatabase | LazilyRefreshDatabase |
|---|---|---|
| 迁移执行时机 | 测试套件启动时 | 首次 DB 操作时 |
| 不接触 DB 的测试开销 | 有 | 无 |
| 推荐场景 | 一般场景 | 测试数量多且大量不接触 DB 时 |
代码覆盖率的度量
需要 Xdebug 或 PCOV。# 在终端展示覆盖率
php artisan test --coverage
# 强制最低覆盖率(低于则 CI 失败)
php artisan test --coverage --min=80
# 输出 HTML 报告
vendor/bin/pest --coverage-html=coverage/
# 查看慢速测试
php artisan test --profile
覆盖率度量会显著增加测试运行时间。建议在 CI 中拆分到专门的 Job,或仅在 Pull Request 时运行。
相关页面
测试入门
了解 Laravel 中测试的基本写法以及
php artisan test 的用法。