跳转到主要内容

什么是 Pest

Pest 是构建在 PHPUnit 之上的测试框架,Laravel 11 之后的新项目默认采用。它保留了 PHPUnit 丰富的断言集合,同时用基于闭包的简洁语法来编写测试。 与 PHPUnit 的主要区别:
视角PestPHPUnit
测试定义test() / it() 闭包用方法定义的类
断言Expectation API(expect()->toBe()$this->assert*()
数据集dataset() / with()@dataProvider
钩子beforeEach() / afterEach()setUp() / tearDown()
嵌套describe() 分组用类分离
Pest 测试仍然通过 PHPUnit 执行,可以与既有的 PHPUnit 测试共存。用 php artisan testvendor/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;
    });
});
也可以只 Fake 特定事件,其它照常执行。
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);
});

RefreshDatabaseLazilyRefreshDatabase

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
视角RefreshDatabaseLazilyRefreshDatabase
迁移执行时机测试套件启动时首次 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 的用法。
最后修改于 2026年7月13日