【PHPテスト入門 第8回】Laravelのフェイク機能とPest

PHP
B!

はじめに

今回のゴールは、Laravelに用意されたテスト用の「フェイク機能」を使いこなし、外部APIやメール送信を含む処理をテストできるようになることです。後半では、テストをより短く書けるテストフレームワーク「Pest」も紹介します。

第5回では、在庫APIをスタブに、メール送信をモックに置き換えてテストしました。Laravelでは、こうした差し替えがフレームワークの機能として用意されています。

この回を読み終えると、次のことができるようになります。

  • Http::fake() で、外部APIへの通信を偽のレスポンスに差し替えられる
  • Http::preventStrayRequests() で、うっかり本物のAPIに通信するのを防げる
  • Mail::fake() で、メールが送られたかを確かめられる
  • $this->mock() で、クラスごと偽物に差し替えられる
  • Pestでテストを書き、PHPUnit版との違いを説明できる

動作確認環境

ソフトウェア動作確認バージョン備考
PHP8.4.26
Laravel13.33.0第7回の shop プロジェクトを引き続き使う
PHPUnit12.5.36 → 13.3.4Pest導入時に13系へ入れ替わる(後述)
Pest5.2.12026年9月時点の最新版

記事中の実行結果は、すべてこの環境で実際に動かして取得したものです。

題材:在庫確認と注文確認メールを追加する

第7回の注文APIに、第5回と同じ2つの機能を加えます。

  • 注文を登録する前に、外部の在庫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 や Mail を使うことになり、テストがエラーになります。

エラーや在庫不足も、自由に再現できる

在庫不足や、在庫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],
]);

主な書き方の対応は次のとおりです。

やりたいことPHPUnitPest
テストを定義するpublic function test_〇〇(): voidtest('〇〇', 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部で学んだ考え方はどちらでも通用します。

PHPUnitPest
書き方クラスとメソッド。記述量は多めだが、構造がはっきりしている関数ベース。短く書ける
情報量歴史が長く、記事や質問への回答が豊富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」の設定を行い、テストを「書いたら終わり」ではなく「毎回必ず実行される」仕組みにします。

参考資料

B!
← 一覧へ戻る