はじめに
今回のゴールは、Laravelに用意されたテスト用の「フェイク機能」を使いこなし、外部APIやメール送信を含む処理をテストできるようになることです。後半では、テストをより短く書けるテストフレームワーク「Pest」も紹介します。
第5回では、在庫APIをスタブに、メール送信をモックに置き換えてテストしました。Laravelでは、こうした差し替えがフレームワークの機能として用意されています。
この回を読み終えると、次のことができるようになります。
Http::fake()で、外部APIへの通信を偽のレスポンスに差し替えられるHttp::preventStrayRequests()で、うっかり本物のAPIに通信するのを防げるMail::fake()で、メールが送られたかを確かめられる$this->mock()で、クラスごと偽物に差し替えられる- Pestでテストを書き、PHPUnit版との違いを説明できる
動作確認環境
| ソフトウェア | 動作確認バージョン | 備考 |
|---|---|---|
| PHP | 8.4.26 | |
| Laravel | 13.33.0 | 第7回の shop プロジェクトを引き続き使う |
| PHPUnit | 12.5.36 → 13.3.4 | Pest導入時に13系へ入れ替わる(後述) |
| Pest | 5.2.1 | 2026年9月時点の最新版 |
記事中の実行結果は、すべてこの環境で実際に動かして取得したものです。
題材:在庫確認と注文確認メールを追加する
- 注文を登録する前に、外部の在庫APIに在庫数を問い合わせる。足りなければ409(Conflict)、在庫APIがエラーなら503(Service Unavailable)を返す
- 注文を登録したら、お客様に確認メールを送る
在庫APIのクライアント
外部APIとの通信は、専用のクラスにまとめます。app/Services/InventoryClient.php を作成します。
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class InventoryClient
{
/**
* @throws \Illuminate\Http\Client\ConnectionException 在庫APIに接続できなかったとき
* @throws \Illuminate\Http\Client\RequestException 在庫APIがエラーを返したとき
*/
public function stock(string $sku): int
{
$response = Http::baseUrl(config('services.inventory.url'))
->timeout(3)
->get("/stock/{$sku}")
->throw();
return (int) $response->json('stock');
}
}
Http はLaravelのHTTPクライアントです。throw() を付けると、APIがエラー(ステータスコード400番台・500番台)を返したときに RequestException が投げられます。
在庫APIのURLは、config/services.php に設定として追加しておきます。
'inventory' => [
'url' => env('INVENTORY_API_URL', 'https://inventory.example.com'),
],
注文確認メール
メールの本文は、Markdown形式のテンプレートで作れます。
php artisan make:mail OrderConfirmed --markdown=mail.orders.confirmed
INFO Mailable [app/Mail/OrderConfirmed.php] created successfully.
INFO Markdown view [resources/views/mail/orders/confirmed.blade.php] created successfully.
app/Mail/OrderConfirmed.php を、注文を受け取る形に書き換えます。
<?php
namespace App\Mail;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class OrderConfirmed extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Order $order,
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'ご注文を受け付けました',
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.orders.confirmed',
);
}
}
テンプレート resources/views/mail/orders/confirmed.blade.php は次のとおりです。
<x-mail::message>
# ご注文ありがとうございます
以下の内容でご注文を受け付けました。
- 商品コード:{{ $order->sku }}
- 数量:{{ $order->quantity }}
{{ config('app.name') }}
</x-mail::message>
コントローラを書き換える
store() の引数に InventoryClient $inventory を追加すると、Laravelが自動でインスタンスを作って渡してくれます。第4回の依存性注入を、フレームワークが肩代わりしてくれるわけです。
use App\Mail\OrderConfirmed;
use App\Services\InventoryClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Mail;
// ...
public function store(Request $request, InventoryClient $inventory): JsonResponse
{
$validated = $request->validate([
'email' => ['required', 'email'],
'sku' => ['required', 'string'],
'quantity' => ['required', 'integer', 'min:1'],
]);
try {
$stock = $inventory->stock($validated['sku']);
} catch (ConnectionException|RequestException) {
return response()->json(['message' => '在庫を確認できませんでした'], 503);
}
if ($stock < $validated['quantity']) {
return response()->json(['message' => '在庫が不足しています'], 409);
}
$order = Order::create($validated);
Mail::to($order->email)->send(new OrderConfirmed($order));
return response()->json($order, 201);
}
既存のテストが失敗する
ここで、第7回のテストを実行してみます。
FAIL Tests\Feature\OrderApiTest
⨯ 注文を登録できる 0.21s
✓ 数量が0なら登録できない 0.01s
✓ メールアドレスで注文を絞り込める 0.04s
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\OrderApiTest > 注文を登録できる
Expected response status code [201] but received 503.
Failed asserting that 503 is identical to 201.
注文登録のテストが、503で失敗しました。テストの中で、inventory.example.com という実在しない在庫APIに本当に通信しようとして、接続に失敗したのです。
本物の在庫APIが用意されていたとしても、テストのたびに通信するのは困りものです。APIが止まっていればテストが落ちますし、在庫数は日々変わります。第4回で「テストしにくいコード」として挙げた状況そのものです。ここで、Laravelのフェイク機能の出番です。
Http::fake() で外部APIを差し替える
偽のレスポンスを返させる
Http::fake() に「URLのパターン」と「返すレスポンス」の組み合わせを渡すと、そのURLへの通信が実際には行われず、指定したレスポンスが返るようになります。
Http::fake([
'inventory.example.com/stock/*' => Http::response(['stock' => 10]),
]);
* はワイルドカードで、/stock/APPLE-001 にも /stock/GRAPE-001 にも一致します。Http::response() の第1引数がJSONの中身、第2引数がステータスコード(省略すると200)です。第5回の createStub() と willReturn() に相当する機能です。
注文登録のテストを、次のように書き直します。
use App\Mail\OrderConfirmed;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
// ...
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
Mail::fake();
}
public function test_注文を登録すると確認メールが送られる(): void
{
Http::fake([
'inventory.example.com/stock/*' => Http::response(['stock' => 10]),
]);
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
]);
$response->assertCreated();
$this->assertDatabaseHas('orders', ['email' => 'taro@example.com', 'quantity' => 2]);
Mail::assertSent(OrderConfirmed::class, function (OrderConfirmed $mail) {
return $mail->hasTo('taro@example.com')
&& $mail->order->sku === 'APPLE-001';
});
Http::assertSent(function (Request $request) {
return $request->url() === 'https://inventory.example.com/stock/APPLE-001';
});
}
setUp() の中身と Mail の部分は後で説明します。まずは Http::assertSent() に注目してください。偽のレスポンスを返すだけでなく、実際にどんなリクエストが送られたかも記録されているので、「正しい商品コードで在庫を問い合わせたか」まで確かめられます。
自分で
setUp()を書くときは、最初に必ずparent::setUp();を呼びます。これを忘れると、Laravelアプリが起動しないままHttpや
エラーや在庫不足も、自由に再現できる
在庫不足や、在庫APIのエラーも、レスポンスを変えるだけで再現できます。
public function test_在庫が足りなければ登録せずメールも送らない(): void
{
Http::fake([
'inventory.example.com/stock/*' => Http::response(['stock' => 1]),
]);
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
]);
$response->assertConflict()
->assertJsonPath('message', '在庫が不足しています');
$this->assertDatabaseCount('orders', 0);
Mail::assertNothingSent();
}
public function test_在庫の問い合わせでエラーなら503を返す(): void
{
Http::fake([
'inventory.example.com/stock/*' => Http::response(null, 500),
]);
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
]);
$response->assertServiceUnavailable();
$this->assertDatabaseCount('orders', 0);
Mail::assertNothingSent();
}
Http::response(null, 500) で、在庫APIが「500 サーバーエラー」を返した状況を作っています。本物のAPIをわざと壊すことはできませんが、フェイクなら1行です。第5回の willThrowException() と同じ発想です。
preventStrayRequests() で、本物への通信を防ぐ
setUp() で呼んでいる Http::preventStrayRequests() は、フェイクを用意していないURLへの通信を、すべてエラーにする設定です。
試しに、1つ目のテストから Http::fake() を消して実行してみます。
FAILED Tests\Feature\OrderApiTest > 注文を登録すると確認メールが送られる
Expected response status code [201] but received 500.
Failed asserting that 500 is identical to 201.
The following exception occurred during the last request:
Illuminate\Http\Client\StrayRequestException: Attempted request to

https://inventory.example.com/stock/APPLE-001 without a matching fake.
「フェイクが用意されていないのに、https://inventory.example.com/stock/APPLE-001 へ通信しようとした」と、どのURLが原因かをはっきり教えてくれます。
この設定がないと、フェイクの書き忘れに気づかないまま、テストが本物のAPIに通信してしまうことがあります。本番の決済APIや、実在するお客様へのメール送信APIだったら大事故です。外部と通信するアプリのテストでは、必ず有効にしておきましょう。
Mail::fake() でメール送信を確かめる
送ったメールを記録させる
setUp() で呼んでいた Mail::fake() は、メールを実際には送らず、「送ろうとしたメール」を記録しておく設定です。テストの最後に、記録を調べて検証します。
Mail::assertSent(OrderConfirmed::class, function (OrderConfirmed $mail) {
return $mail->hasTo('taro@example.com')
&& $mail->order->sku === 'APPLE-001';
});
第1引数は、送られたはずのメールのクラスです。第2引数の関数では、送られたメールの中身を調べ、条件を満たすときに true を返します。ここでは「宛先が taro@example.com で、注文の商品コードが APPLE-001 であること」を確かめています。
在庫不足やエラーのテストでは、Mail::assertNothingSent() で、メールが1通も送られていないことを確かめました。注文が成立していないのに「ご注文を受け付けました」とメールが届いたら、お客様は混乱してしまいます。
メールの送り忘れを見つける
テストの効果を確かめるため、コントローラの Mail::to(...)->send(...) の行をコメントアウトしてみます。
FAILED Tests\Feature\OrderApiTest > 注文を登録すると確認メールが送られる
The expected [App\Mail\OrderConfirmed] mailable was not sent.
Failed asserting that false is true.
「OrderConfirmed が送られるはずだったが、送られなかった」と教えてくれます。注文の登録自体は成功しているので、レスポンスやDBだけを調べるテストでは、この送り忘れに気づけません。第5回でモックを使って確かめた「外に向けた行動」を、Laravelではフェイクで確かめているわけです。
【スクショ:メールの送り忘れで Mail::assertSent が失敗した実行結果】
phpunit.xmlのMAIL_MAILER=arrayの設定(第7回)でも、テスト中にメールが実際に送られることはありません。ただ、それだけでは「送られたかどうか」を検証できません。検証したいときはMail::fake()を使います。
ほかのフェイク
Laravelには、メールとHTTP以外にも、次のようなフェイクが用意されています。使い方はどれも「〇〇::fake() で記録を始め、〇〇::assert〇〇() で検証する」という同じ流れです。
| フェイク | 差し替える対象 | 検証の例 |
|---|---|---|
Http::fake() | 外部APIへのHTTP通信 | Http::assertSent() |
Mail::fake() | メール送信 | Mail::assertSent() |
Queue::fake() | キューへのジョブ登録 | Queue::assertPushed() |
Event::fake() | イベントの発行 | Event::assertDispatched() |
Notification::fake() | 通知(メール・Slackなど) | Notification::assertSentTo() |
Storage::fake() | ファイルの保存先 | Storage::disk('local')->assertExists() |
今回扱わなかったものも、必要になったときにLaravelの公式ドキュメントの「Mocking」のページを見れば、同じ感覚で使えるはずです。
$this->mock() でクラスごと差し替える
Http::fake() は、HTTP通信という「一番外側」を差し替える方法でした。もう1つ、InventoryClient クラスそのものを偽物に差し替える方法もあります。
<?php
namespace Tests\Feature;
use App\Services\InventoryClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Mockery\MockInterface;
use Tests\TestCase;
class OrderWithMockTest extends TestCase
{
use RefreshDatabase;
public function test_在庫クラスを差し替えて注文を登録する(): void
{
Mail::fake();
$this->mock(InventoryClient::class, function (MockInterface $mock) {
$mock->shouldReceive('stock')
->once()
->with('APPLE-001')
->andReturn(10);
});
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
]);
$response->assertCreated();
}
}
PASS Tests\Feature\OrderWithMockTest
✓ 在庫クラスを差し替えて注文を登録する 0.14s
Tests: 1 passed (2 assertions)
$this->mock() は、Laravelに「このテストの間、InventoryClient が必要になったら、この偽物を渡して」と登録するメソッドです。コントローラの store() が引数で InventoryClient を受け取るとき、Laravelが本物の代わりにこの偽物を渡します。
偽物の作成には、第5回で名前だけ紹介したMockeryというライブラリが使われています。Laravelのプロジェクトには最初から入っています。PHPUnitの書き方と見比べると、対応は次のとおりです。
| やりたいこと | PHPUnit(第5回) | Mockery |
|---|---|---|
| 対象のメソッド | ->method('stock') | ->shouldReceive('stock') |
| 呼ばれる回数 | ->expects($this->once()) | ->once() |
| 引数 | ->with('APPLE-001') | ->with('APPLE-001') |
| 返す値 | ->willReturn(10) | ->andReturn(10) |
第5回で「
finalクラスは偽物にできない」と説明しました。InventoryClientにfinalを付けていないのは、このようにクラスごと差し替えられるようにするためです。インターフェースを切り出して、それを差し替える方法もあります。
Http::fake() と $this->mock() の使い分け
どちらも在庫APIを偽物にできますが、確かめられる範囲が違います。
Http::fake() | $this->mock() | |
|---|---|---|
| 差し替える場所 | HTTP通信(一番外側) | InventoryClient クラス |
InventoryClient の中身 | 本物が動く | 動かない |
| 確かめられること | URLの組み立て、JSONの読み取り、エラー処理まで含めて確認できる | コントローラが在庫の結果をどう扱うかに集中できる |
| 向いている場面 | 外部APIとのやり取りを含めて確かめたいとき | 差し替えたいクラスの中身が複雑なとき |
この回では、主に Http::fake() を使いました。InventoryClient のURLの組み立てや、throw() によるエラー処理まで、本物のコードを通して確かめられるからです。第5回の「迷ったらスタブ、モックの使いすぎに注意」という方針は、Laravelでも同じです。
Pest:もっと短くテストを書く
Pestとは
Pestは、PHPUnitの上に作られたテストフレームワークです。中ではPHPUnitが動いているので、ここまで学んだ考え方やアサーションはそのまま使えます。違いは書き方で、クラスやメソッドを定義せず、関数でテストを書きます。
Laravelの新規プロジェクト作成時にPestを選べるなど、Laravel界隈では広く使われています。第2部の最後に、PHPUnitとの違いを見ておきましょう。
インストール
Pest 5は、特定のバージョンのPHPUnitと組み合わせて動きます。そのため、公式の手順に従い、いったんPHPUnitを外してからPestを入れます。
composer remove phpunit/phpunit --dev
composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
途中で「pestphp/pest-plugin を信頼して有効にするか」を聞かれたら、y で許可してください。Pestが動くのに必要なComposerのプラグインです。
vendor/bin/pest --version
Pest Testing Framework 5.2.1.
Pestと一緒に、PHPUnitは13.3.4に入れ替わりました。PestがPHPUnitの特定のバージョンに合わせて作られているためです。
vendor/bin/phpunit --version
PHPUnit 13.3.4 by Sebastian Bergmann and contributors.
最後に、Pestの初期設定ファイルを作ります。
vendor/bin/pest --init
tests/Pest.php と、サンプルのテストが作られます。サンプルのテストは、この回では削除しています。tests/Pest.php には、次のような設定が入っています。
pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class)
->in('Feature');
「tests/Feature フォルダのPestのテストは、Tests\TestCase を継承したものとして動かす」という意味です。第7回で説明した「Featureのテストは、Laravelを起動する」を、Pestではこの設定で実現しています。
PHPUnit版とPest版を見比べる
第3回の Cart のテストを、Pestで書き直してみます。tests/Unit/CartPestTest.php を作成します。
<?php
use App\Cart\Cart;
test('数量を指定すると単価×数量が合計金額になる', function () {
$cart = new Cart();
$cart->add('りんご', 150, 3);
$cart->add('みかん', 80, 2);
expect($cart->total())->toBe(610);
});
test('数量が0なら例外を投げる', function () {
(new Cart())->add('りんご', 150, 0);
})->throws(InvalidArgumentException::class, '数量は1以上を指定してください');
test('税込金額は1円未満を切り捨てる', function (int $subtotal, int $expected) {
$cart = new Cart();
$cart->add('商品', $subtotal);
expect($cart->totalWithTax())->toBe($expected);
})->with([
'端数0.5は切り捨て' => [105, 115],
'端数0.9も切り捨て' => [999, 1098],
]);
主な書き方の対応は次のとおりです。
| やりたいこと | PHPUnit | Pest |
|---|---|---|
| テストを定義する | public function test_〇〇(): void | test('〇〇', function () { ... }); |
| 値を比べる | $this->assertSame(610, $total) | expect($total)->toBe(610) |
| 例外を確かめる | $this->expectException(...) を先に書く | ->throws(...) を後ろに付ける |
| データプロバイダ | #[DataProvider('メソッド名')] + 静的メソッド | ->with([...]) |
| 毎回の準備 | setUp() | beforeEach(function () { ... }) |
クラスの定義や use 文、型の宣言が減り、テストの本題が目立つ書き方になっています。expect(値)->toBe(期待値) は「値が期待値であることを期待する」と、英語の文章のように読めるのも特徴です。toBe() は assertSame() と同じく、型まで比べます。
Feature のテストもPestで書ける
注文APIのテストも、Pestで書いてみます。tests/Feature/OrderApiPestTest.php を作成します。
<?php
use App\Mail\OrderConfirmed;
use App\Models\Order;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Http::preventStrayRequests();
Mail::fake();
});
test('注文を登録すると確認メールが送られる', function () {
Http::fake([
'inventory.example.com/stock/*' => Http::response(['stock' => 10]),
]);
$this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
])->assertCreated();
Mail::assertSent(OrderConfirmed::class, fn (OrderConfirmed $mail) => $mail->hasTo('taro@example.com'));
});
test('在庫が足りなければ登録しない', function () {
Http::fake([
'inventory.example.com/stock/*' => Http::response(['stock' => 1]),
]);
$this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
])->assertConflict();
expect(Order::count())->toBe(0);
Mail::assertNothingSent();
});
test('数量が0以下なら登録できない', function (int $quantity) {
$this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => $quantity,
])->assertUnprocessable();
})->with([0, -1]);
$this->postJson() や assertCreated()、Http::fake() など、Laravelの機能はPestでもそのまま使えます。uses(RefreshDatabase::class) は、このファイルのテストで RefreshDatabase を使う、という指定です。
Pestのテストは
test()の代わりにit()でも書けます。it('creates an order', ...)のように英語で「それは〜する」と書くための関数で、表示の頭にitが付きます。日本語でテスト名を書くなら、test()のほうが自然に読めます。
実行する
php artisan test は、Pestを入れると自動的にPestで実行されるようになります。PHPUnitで書いたテストもPestで書いたテストも、まとめて実行できます。
PASS Tests\Unit\CartPestTest
✓ 数量を指定すると単価×数量が合計金額になる
✓ 数量が0なら例外を投げる
✓ 税込金額は1円未満を切り捨てる with dataset "端数0.5は切り捨て"
✓ 税込金額は1円未満を切り捨てる with dataset "端数0.9も切り捨て"
PASS Tests\Feature\OrderApiPestTest
✓ 注文を登録すると確認メールが送られる 0.16s
✓ 在庫が足りなければ登録しない 0.01s
✓ 数量が0以下なら登録できない with (0) 0.01s
✓ 数量が0以下なら登録できない with (-1) 0.01s
失敗したときの表示も、PHPUnit版と同じく該当行に ➜ が付きます。試しに期待値を600に変えてみた結果です。
FAILED Tests\Unit\CartPestTest > 数量を指定すると単価×数量が合計金額にな…
Failed asserting that 610 is identical to 600.
at tests/Unit/CartPestTest.php:10
6▕ $cart = new Cart();
7▕ $cart->add('りんご', 150, 3);
8▕ $cart->add('みかん', 80, 2);
9▕
➜ 10▕ expect($cart->total())->toBe(600);
11▕ });
【スクショ:Pest のテストと PHPUnit のテストがまとめて実行され、すべて PASS になった結果】
PHPUnitとPest、どちらを使うべきか
結論から言うと、チームやプロジェクトで決まっているほうを使えば十分です。中身は同じPHPUnitなので、第1部で学んだ考え方はどちらでも通用します。
| PHPUnit | Pest | |
|---|---|---|
| 書き方 | クラスとメソッド。記述量は多めだが、構造がはっきりしている | 関数ベース。短く書ける |
| 情報量 | 歴史が長く、記事や質問への回答が豊富 | Laravel界隈を中心に増えている |
| 向いている場面 | PHP全般、フレームワークを使わないプロジェクト | Laravelのプロジェクト、テストを手軽に書きたいとき |
既存のPHPUnitのテストを残したまま、新しいテストだけPestで書く、という段階的な移行もできます。今回のプロジェクトも、PHPUnit版とPest版が同居した状態で動いています。
今回の完成コード
今回作成・変更したファイルは次のとおりです。
shop/
├── app/
│ ├── Http/Controllers/
│ │ └── OrderController.php ← store() を変更
│ ├── Mail/
│ │ └── OrderConfirmed.php ← 追加
│ └── Services/
│ └── InventoryClient.php ← 追加
├── config/
│ └── services.php ← inventory の設定を追加
├── resources/views/mail/orders/
│ └── confirmed.blade.php ← 追加
└── tests/
├── Pest.php ← pest --init で作成
├── Feature/
│ ├── OrderApiPestTest.php ← 追加(Pest版)
│ ├── OrderApiTest.php ← フェイクを使う形に変更
│ └── OrderWithMockTest.php ← 追加
└── Unit/
├── CartPestTest.php ← 追加(Pest版)
└── CartTest.php
OrderApiTest.php は本文で分けて紹介したので、全体をまとめて載せておきます。
tests/Feature/OrderApiTest.php
<?php
namespace Tests\Feature;
use App\Mail\OrderConfirmed;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class OrderApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
Mail::fake();
}
public function test_注文を登録すると確認メールが送られる(): void
{
Http::fake([
'inventory.example.com/stock/*' => Http::response(['stock' => 10]),
]);
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
]);
$response->assertCreated();
$this->assertDatabaseHas('orders', ['email' => 'taro@example.com', 'quantity' => 2]);
Mail::assertSent(OrderConfirmed::class, function (OrderConfirmed $mail) {
return $mail->hasTo('taro@example.com')
&& $mail->order->sku === 'APPLE-001';
});
Http::assertSent(function (Request $request) {
return $request->url() === 'https://inventory.example.com/stock/APPLE-001';
});
}
public function test_在庫が足りなければ登録せずメールも送らない(): void
{
Http::fake([
'inventory.example.com/stock/*' => Http::response(['stock' => 1]),
]);
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
]);
$response->assertConflict()
->assertJsonPath('message', '在庫が不足しています');
$this->assertDatabaseCount('orders', 0);
Mail::assertNothingSent();
}
public function test_在庫の問い合わせでエラーなら503を返す(): void
{
Http::fake([
'inventory.example.com/stock/*' => Http::response(null, 500),
]);
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 2,
]);
$response->assertServiceUnavailable();
$this->assertDatabaseCount('orders', 0);
Mail::assertNothingSent();
}
public function test_数量が0なら登録できない(): void
{
$response = $this->postJson('/api/orders', [
'email' => 'taro@example.com',
'sku' => 'APPLE-001',
'quantity' => 0,
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['quantity']);
$this->assertDatabaseCount('orders', 0);
Http::assertNothingSent();
Mail::assertNothingSent();
}
public function test_メールアドレスで注文を絞り込める(): void
{
Order::factory()->create(['email' => 'taro@example.com', 'sku' => 'APPLE-001']);
Order::factory()->create(['email' => 'hanako@example.com']);
Order::factory()->create(['email' => 'taro@example.com', 'sku' => 'GRAPE-001']);
$response = $this->getJson('/api/orders?email=taro@example.com');
$response->assertOk()
->assertJsonCount(2)
->assertJsonPath('0.sku', 'APPLE-001')
->assertJsonPath('1.sku', 'GRAPE-001');
}
}
数量0のテストでは、Http::assertNothingSent() で「入力エラーのときは在庫APIに問い合わせない」ことも確かめています。無駄な通信をしていないかも、テストで守れるわけです。
プロジェクト全体のテストを実行すると、PHPUnit版とPest版を合わせて22件がすべて通ります。
Tests: 22 passed (46 assertions)
Duration: 0.31s
まとめ
Http::fake()で外部APIのレスポンスを差し替え、在庫不足やサーバーエラーも自由に再現できるHttp::preventStrayRequests()で、フェイクの書き忘れによる本物への通信を防ぐMail::fake()とMail::assertSent()で、メールが送られたか、宛先や中身が正しいかを確かめる$this->mock()で、クラスごと偽物に差し替えられる(中ではMockeryが動いている)- Pestは、PHPUnitの上に作られた、関数でテストを書くフレームワーク。考え方は同じで、PHPUnitのテストと同居できる
次回予告
第9回からは第3部、実践編に入ります。第9回は「カバレッジとCI」です。テストがコードのどこまでを確かめているかを測る「カバレッジ」の計測方法と、その数字との上手な付き合い方を扱います。さらに、GitHubにコードをpushするたびに自動でテストを実行する「GitHub Actions」の設定を行い、テストを「書いたら終わり」ではなく「毎回必ず実行される」仕組みにします。
参考資料
- 第1回:なぜテストを書くのか
- 第2回:PHPUnitの環境構築と最初のテスト
- 第3回:読みやすいテストの書き方
- 第4回:テストしやすいコードの書き方
- 第5回:テストダブル
- 第6回:DBを使うテスト
- 第7回:Laravelのテスト基盤
- 第8回:Laravelのフェイク機能とPest(今回)
- 第9回:カバレッジとCI
- 第10回:TDDとレガシーコード (10/7 公開予定)
Analyzegear

