> ## 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、Dataset、Fake、Mockery。

## 什麼是 Pest

[Pest](https://pestphp.com) 是建構於 PHPUnit 之上的測試框架，自 Laravel 11 以後於新專案中預設採用。可繼續使用 PHPUnit 豐富的斷言集，同時以 closure 為基礎的簡潔語法撰寫測試。

與 PHPUnit 的主要差異：

| 觀點      | Pest                                | PHPUnit                  |
| ------- | ----------------------------------- | ------------------------ |
| 測試定義    | `test()` / `it()` closure           | 以方法定義的類別                 |
| 斷言      | Expectation API（`expect()->toBe()`） | `$this->assert*()`       |
| Dataset | `dataset()` / `with()`              | `@dataProvider`          |
| Hook    | `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 的參數化測試

若要以多個輸入值測試相同邏輯，可使用 `dataset()` 或 inline 的 `with()`。

### Inline Dataset

```php theme={null}
it('無效的電子郵件會產生驗證錯誤', function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

    $response->assertInvalid('email');
})->with([
    '純文字' => ['not-an-email'],
    '無網域' => ['user@'],
    '空字串' => [''],
]);
```

### 具名 Dataset

於 `tests/Datasets` 目錄定義 Dataset。

```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');
```

### 使用 Closure 的動態 Dataset

```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, 'twd')
            ->andReturn(['status' => 'succeeded']);
    });

    $result = app(PaymentGateway::class)->charge(1000, 'twd');

    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 請求即 stub 回應。

### 基本 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();
});
```

## 事件、Mail、Notification 的 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`

於各測試後將資料庫 rollback，保持乾淨狀態。於測試 suite 開始時執行 migration。

```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` 會於所有測試中確認 migration，而 `LazilyRefreshDatabase` 會將 migration 延遲至實際會變更資料庫的測試被執行為止。若有大量不觸及資料庫的測試，可以加速。

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

<Tip>
  對大多數專案而言 `RefreshDatabase` 為安全的選擇。若測試 suite 龐大且有大量無資料庫操作的測試時，可考慮 `LazilyRefreshDatabase`。
</Tip>

| 觀點             | `RefreshDatabase` | `LazilyRefreshDatabase` |
| -------------- | ----------------- | ----------------------- |
| Migration 執行時機 | 測試 suite 開始時      | 首次 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-TW/testing">
  確認 Laravel 中測試的基本撰寫方式與 `php artisan test` 的使用方式。
</Card>


## Related topics

- [套件的版本相容性管理](/zh-TW/advanced/package-versioning.md)
- [測試](/zh-TW/packages/laravel-bluesky/testing.md)
- [以 Orchestra Testbench 測試 Laravel 套件](/zh-TW/advanced/package-testing.md)
- [用 Pest PHP 開始 Laravel 測試](/zh-TW/blog/pest-introduction.md)
- [GeneratorCommand — 自訂 make: 指令的實作](/zh-TW/advanced/generator-command.md)
