> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 使用 Pest 进行进阶测试

> 系统讲解 Laravel 11 及以后默认的 Pest 的进阶用法，涵盖 Expectation API、数据集、Fake、Mockery。

## 什么是 Pest

[Pest](https://pestphp.com) 是构建在 PHPUnit 之上的测试框架，Laravel 11 之后的新项目默认采用。它保留了 PHPUnit 丰富的断言集合，同时用基于闭包的简洁语法来编写测试。

与 PHPUnit 的主要区别：

| 视角   | Pest                                | PHPUnit                  |
| ---- | ----------------------------------- | ------------------------ |
| 测试定义 | `test()` / `it()` 闭包                | 用方法定义的类                  |
| 断言   | Expectation API（`expect()->toBe()`） | `$this->assert*()`       |
| 数据集  | `dataset()` / `with()`              | `@dataProvider`          |
| 钩子   | `beforeEach()` / `afterEach()`      | `setUp()` / `tearDown()` |
| 嵌套   | 用 `describe()` 分组                   | 用类分离                     |

<Info>
  Pest 测试仍然通过 PHPUnit 执行，可以与既有的 PHPUnit 测试共存。用 `php artisan test` 或 `vendor/bin/pest` 运行。
</Info>

## `describe` / `it` / `test` 的用法差异

### `test()`

最简单的定义。测试名会作为说明使用。

```php theme={null}
test('用户可以用邮箱登录', function () {
    $user = User::factory()->create();

    $response = $this->post('/login', [
        'email' => $user->email,
        'password' => 'password',
    ]);

    $response->assertRedirect('/dashboard');
});
```

### `it()`

「it should...」这种英文文体，读起来接近自然语言。

```php theme={null}
it('会将未认证用户重定向到登录页', function () {
    $response = $this->get('/dashboard');

    $response->assertRedirect('/login');
});
```

### `describe()`

将相关测试分组。可用于共享 `beforeEach()` 或在文件内进行逻辑整理。

```php theme={null}
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');
    });
});
```

<Tip>
  `describe()` 可以嵌套，但过深的嵌套会让测试难读，一般建议不超过 2 层。
</Tip>

## Expectation API

`expect()` 是 Pest 特有的断言语法，可以链式叠加条件。

### 基本断言

```php theme={null}
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);
```

### 针对模型的断言

```php theme={null}
expect($user)->toBeInstanceOf(User::class);
expect($user->email)->toMatchRegex('/^.+@.+\..+$/');

// 确认已保存到数据库
expect(User::where('email', 'test@example.com')->exists())->toBeTrue();
```

### `and()` 链

```php theme={null}
expect($response->status())->toBe(200)
    ->and($response->json('name'))->toBe('Laravel')
    ->and($response->json('version'))->toBeGreaterThan(12);
```

### 用 `each()` 校验数组每个元素

```php theme={null}
$users = User::factory(3)->create();

expect($users)->each(function ($user) {
    $user->toBeInstanceOf(User::class)
        ->email->not->toBeNull();
});
```

## 使用数据集进行参数化测试

对同一逻辑使用多个输入进行测试时，使用 `dataset()` 或行内 `with()`。

### 行内数据集

```php theme={null}
it('无效邮箱应产生校验错误', function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

    $response->assertInvalid('email');
})->with([
    '纯文本' => ['not-an-email'],
    '缺少域名' => ['user@'],
    '空字符串' => [''],
]);
```

### 命名数据集

在 `tests/Datasets` 目录中定义数据集。

```php theme={null}
// tests/Datasets/InvalidEmails.php
dataset('invalid_emails', [
    'plain' => ['not-an-email'],
    'no-domain' => ['user@'],
    'empty' => [''],
    'spaces' => ['user @example.com'],
]);
```

```php theme={null}
it('无效邮箱应产生校验错误', function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

    $response->assertInvalid('email');
})->with('invalid_emails');
```

### 使用闭包的动态数据集

```php theme={null}
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

```php theme={null}
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 不同，它在调用真实实现的同时记录调用。

```php theme={null}
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，其余方法使用真实实现。

```php theme={null}
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

```php theme={null}
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');
});
```

### 错误响应的测试

```php theme={null}
test('API 返回错误时抛出异常', function () {
    Http::fake([
        'api.example.com/*' => Http::response([], 503),
    ]);

    expect(fn () => app(UserApiClient::class)->find(1))
        ->toThrow(\App\Exceptions\ApiUnavailableException::class);
});
```

### 模拟网络故障

```php theme={null}
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()`

```php theme={null}
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 特定事件，其它照常执行。

```php theme={null}
Event::fake([OrderCreated::class]);
```

### `Mail::fake()`

```php theme={null}
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;

test('用户注册时发送欢迎邮件', function () {
    Mail::fake();

    $this->post('/register', [
        'name' => 'Test User',
        'email' => 'test@example.com',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);

    Mail::assertSent(WelcomeEmail::class, function ($mail) {
        return $mail->hasTo('test@example.com');
    });
});
```

### `Notification::fake()`

```php theme={null}
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 命令的测试

```php theme={null}
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();
});
```

### 交互式命令的测试

```php theme={null}
test('交互式命令确认后执行处理', function () {
    $this->artisan('reports:generate')
        ->expectsQuestion('要执行吗？', 'yes')
        ->expectsOutput('报表已生成。')
        ->assertExitCode(0);
});
```

## `RefreshDatabase` 与 `LazilyRefreshDatabase`

### `RefreshDatabase`

在每个测试后回滚数据库以保持干净状态。测试套件启动时会执行迁移。

```php theme={null}
// tests/Pest.php
uses(RefreshDatabase::class)->in('Feature');
```

```php theme={null}
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` 会延迟到实际会修改数据库的测试运行时才执行迁移。当有很多不接触数据库的测试时可加速。

```php theme={null}
// tests/Pest.php
uses(LazilyRefreshDatabase::class)->in('Feature');
```

<Tip>
  多数项目选择 `RefreshDatabase` 更安全。当测试规模庞大且大量测试不接触数据库时，可以考虑 `LazilyRefreshDatabase`。
</Tip>

| 视角           | `RefreshDatabase` | `LazilyRefreshDatabase` |
| ------------ | ----------------- | ----------------------- |
| 迁移执行时机       | 测试套件启动时           | 首次 DB 操作时               |
| 不接触 DB 的测试开销 | 有                 | 无                       |
| 推荐场景         | 一般场景              | 测试数量多且大量不接触 DB 时        |

## 代码覆盖率的度量

需要 Xdebug 或 PCOV。

```bash theme={null}
# 在终端展示覆盖率
php artisan test --coverage

# 强制最低覆盖率（低于则 CI 失败）
php artisan test --coverage --min=80

# 输出 HTML 报告
vendor/bin/pest --coverage-html=coverage/

# 查看慢速测试
php artisan test --profile
```

<Warning>
  覆盖率度量会显著增加测试运行时间。建议在 CI 中拆分到专门的 Job，或仅在 Pull Request 时运行。
</Warning>

## 相关页面

<Card title="测试入门" icon="flask" href="/zh/testing">
  了解 Laravel 中测试的基本写法以及 `php artisan test` 的用法。
</Card>


## Related topics

- [包的版本兼容性管理](/zh/advanced/package-versioning.md)
- [使用 Testbench Workbench 进行包开发](/zh/advanced/package-workbench.md)
- [测试](/zh/packages/laravel-bluesky/testing.md)
- [使用 Orchestra Testbench 测试 Laravel 包](/zh/advanced/package-testing.md)
- [用 Pest PHP 开始 Laravel 测试](/zh/blog/pest-introduction.md)
