【PHPテスト入門 第7回】Laravelのテスト基盤:HTTPテスト・RefreshDatabase・Factory

PHP
B!

はじめに

今回から第2部、Laravel編に入ります。第1部では、テストの仕組みを素のPHPで一つひとつ手作りしてきました。DBの準備と後片付け、テストデータを楽に作るヘルパー、依存性注入などです。

Laravelは、こうした仕組みをフレームワークの機能として最初から用意しています。今回のゴールは、第1部で学んだ考え方が、Laravelのどの機能に対応するのかを理解し、LaravelアプリのAPIをテストできるようになることです。

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

  • Laravelの tests/Unit と tests/Feature の違いを説明できる
  • HTTPテストで、APIにリクエストを送ってレスポンスを検証できる
  • RefreshDatabase で、テストごとにDBをきれいな状態にできる
  • Factoryで、テストデータを手軽に用意できる

動作確認環境

ソフトウェア動作確認バージョン備考
PHP8.4.26第1部と同じ
Laravel13.33.02026年9月時点の最新版
PHPUnit12.5.36Laravelの初期設定のバージョン

第1部ではPHPUnit 13を使いましたが、Laravel 13の新規プロジェクトには、初期設定でPHPUnit 12が入ります。Laravel本体はPHPUnit 13にも対応していますが、この回ではLaravelの初期設定どおりに進めます。今回扱う範囲では、12と13で書き方の違いはありません。

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

Laravelプロジェクトを用意する

プロジェクトの作成

第1部の php-test-cart とは別に、Laravelの新しいプロジェクトを作ります。名前は shop としました。

composer create-project laravel/laravel shop
cd shop

作成の途中で、アプリケーションキーの生成、SQLiteのDBファイル(database/database.sqlite)の作成、マイグレーションまで自動で行われます。バージョンを確認しておきましょう。

php artisan --version
Laravel Framework 13.33.0

【スクショ:composer create-project の完了後、php artisan –version で Laravel 13 が表示された状態】

最初からテストが用意されている

新しいプロジェクトには、サンプルのテストが2つ入っています。実行してみましょう。Laravelでは、vendor/bin/phpunit の代わりに php artisan test で実行できます。

php artisan test
   PASS  Tests\Unit\ExampleTest
  ✓ that true is true

   PASS  Tests\Feature\ExampleTest
  ✓ the application returns a successful response                        0.12s

  Tests:    2 passed (2 assertions)
  Duration: 0.21s

第1部で見てきた ... の表示とは見た目が違いますが、中で動いているのは同じPHPUnitです。php artisan test は、テスト名の一覧や失敗箇所のコードを見やすく表示してくれる、Laravel向けの実行コマンドです。vendor/bin/phpunit でも同じテストを実行できます。

サンプルの tests/Unit/ExampleTest.php と tests/Feature/ExampleTest.php は、中身のないテストです。この回では、自分のテストを追加した後に削除しています。

phpunit.xml に、テスト用の設定がそろっている

プロジェクト直下の phpunit.xml を開いてみてください。第2回で自分で書いたものと比べると、<php> という部分が増えています。抜粋すると次のとおりです。

<php>
    <env name="APP_ENV" value="testing"/>
    <env name="BCRYPT_ROUNDS" value="4"/>
    <env name="CACHE_STORE" value="array"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
    <env name="MAIL_MAILER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="SESSION_DRIVER" value="array"/>
</php>

これは、テストを実行するときだけ環境変数を上書きする設定です。主なものの意味は次のとおりです。

設定意味
DB_CONNECTION=sqlite、DB_DATABASE=:memory:テストではSQLiteのインメモリDBを使う(第6回と同じ考え方)
MAIL_MAILER=arrayメールを実際には送らず、配列に溜めておく
CACHE_STORE=array、SESSION_DRIVER=arrayキャッシュやセッションをメモリ上だけで扱う
BCRYPT_ROUNDS=4パスワードのハッシュ計算を軽くして、テストを速くする

第6回で手作りした「テストではインメモリDBを使う」という工夫が、Laravelでは最初から設定されているわけです。開発用の .env でMySQLを使っていても、テストはこの設定でインメモリDBに切り替わります。

Unit と Feature の違い

Laravelのテストは、tests/Unit と tests/Feature の2つのフォルダに分かれています。違いは、テストクラスが何を継承しているかです。

フォルダ継承するクラスLaravelアプリの起動向いているテスト
tests/UnitPHPUnit\Framework\TestCaseしない計算やロジックだけのクラス(第1部のようなユニットテスト)
tests/FeatureTests\TestCaseするDB・HTTPリクエスト・メールなど、Laravelの機能を使うテスト

Tests\TestCase は、テストごとにLaravelアプリを起動してくれるクラスです。そのおかげでDBやルーティングが使えますが、その分だけ実行に時間がかかります。

第1部のCartは、そのまま Unit で動く

試しに、第3回で作った Cart クラスとそのテストを、Laravelプロジェクトに持ってきます。

  • Cart.php を app/Cart/Cart.php に置く(名前空間は App\Cart のまま)
  • CartTest.php を tests/Unit/CartTest.php に置き、名前空間を Tests\Unit に変える

Laravelの composer.json も App\ を app/ に対応させているので、Cart.php は1文字も変えずに動きます。テストも、名前空間の1行以外はそのままです。

テストスイート(テストのグループ)を指定して、Unitだけを実行してみます。

php artisan test --testsuite=Unit
   PASS  Tests\Unit\CartTest
  ✓ 空のカートの合計金額は0円
  ✓ 数量を指定すると単価×数量が合計金額になる
  ✓ 単価がマイナスなら例外を投げる
  ✓ 数量が0なら例外を投げる
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "0円は0円"
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "端数が出ない"
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "端数0.5は切り捨て"
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "端数0.9も切り捨て"

  Tests:    8 passed (11 assertions)
  Duration: 0.08s

Laravelの中にあっても、フレームワークに依存しないクラスは、第1部と同じ普通のユニットテストで確かめられます。第4回で学んだ「外部に依存しない設計」は、フレームワークを使うときにもそのまま役立つのです。

php artisan test の表示では、第3回で付けた #[TestDox] の説明文ではなく、メソッド名とデータセット名が使われます。--testdox オプションの表示とは別物です。

Unit のテストでは、DBは使えない

逆に、tests/Unit のテストでLaravelの機能を使おうとすると、エラーになります。試しに、Unitのテストの中でDBに注文を保存しようとした結果です。

   FAIL  Tests\Unit\FacadeTest
  ⨯ ユニットテストではデータベースは使えない                             0.03s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Unit\FacadeTest > ユニットテストではデータベースは使…  Error
  Call to a member function connection() on null

PHPUnit\Framework\TestCase はLaravelアプリを起動しないため、DB接続が用意されていないのです。DBやHTTPを使うテストは、tests/Feature に書きましょう。

テストファイルは php artisan make:test 〇〇Test で作れます。--unit を付けると tests/Unit に、付けなければ tests/Feature に作られます。

題材:注文API を作る

第6回ではリポジトリを手作りして注文を保存しました。今回は同じことを、LaravelのAPIとして作ります。

  • POST /api/orders:注文を登録する(メールアドレス・商品コード・数量を受け取る)
  • GET /api/orders?email=...:メールアドレスで注文を絞り込んで返す

モデル・マイグレーション・Factoryを作る

-mf オプションを付けると、モデルと一緒に、マイグレーションとFactoryもまとめて作られます。

php artisan make:model Order -mf
   INFO  Model [app/Models/Order.php] created successfully.

   INFO  Factory [database/factories/OrderFactory.php] created successfully.

   INFO  Migration [database/migrations/2026_09_26_135627_create_orders_table.php] created successfully.

マイグレーションファイル名の日時の部分は、実行した日時によって変わります。

マイグレーションの up() に、第6回の schema.sql と同じ列を追加します。

public function up(): void
{
    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->string('email');
        $table->string('sku');
        $table->unsignedInteger('quantity');
        $table->timestamps();
    });
}

モデル app/Models/Order.php では、一括で代入してよい列を #[Fillable] 属性で指定します。Laravel 13の新規プロジェクトでは、User モデルもこの書き方になっています。

<?php

namespace App\Models;

use Database\Factories\OrderFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['email', 'sku', 'quantity'])]
class Order extends Model
{
    /** @use HasFactory<OrderFactory> */
    use HasFactory;
}

以前のバージョンのLaravelでは、protected $fillable = ['email', 'sku', 'quantity']; とプロパティで書いていました。こちらの書き方も引き続き使えます。

コントローラを作る

php artisan make:controller OrderController

app/Http/Controllers/OrderController.php を次のように書きます。入力のチェック(バリデーション)には、Laravelの validate() を使います。

<?php

namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'email' => ['required', 'email'],
        ]);

        $orders = Order::where('email', $validated['email'])
            ->orderBy('id')
            ->get();

        return response()->json($orders);
    }

    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'email' => ['required', 'email'],
            'sku' => ['required', 'string'],
            'quantity' => ['required', 'integer', 'min:1'],
        ]);

        $order = Order::create($validated);

        return response()->json($order, 201);
    }
}

store() では、数量に min:1 のルールを付けています。第3回で Cart に入れた「数量は1以上」のチェックと同じ仕様です。登録に成功したら、ステータスコード201(Created)で注文の内容を返します。

APIのルートを登録する

Laravel 13の新規プロジェクトには、API用のルートファイル routes/api.php がありません。自分で作成します。

<?php

use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;

Route::get('/orders', [OrderController::class, 'index']);
Route::post('/orders', [OrderController::class, 'store']);

次に、bootstrap/app.php の withRouting() に api: の1行を追加して、このファイルを読み込ませます。

->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
)

api: で登録したルートには、自動で /api が頭に付きます。登録できたか確認しましょう。

php artisan route:list --path=api
  GET|HEAD   api/orders ................................ OrderController@index
  POST       api/orders ................................ OrderController@store

                                                            Showing [2] routes

php artisan install:api というコマンドでも routes/api.php を作れます。ただし、API認証用のパッケージ(Sanctum)も一緒にインストールされます。この回では認証を扱わないので、手で作る方法にしました。

HTTPテストで API を確かめる

テストファイルを作る

php artisan make:test OrderApiTest

--unit を付けていないので、tests/Feature/OrderApiTest.php に作られます。中身を次のように書き換えます。まずは注文登録の2つのテストです。

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_注文を登録できる(): void
    {
        $response = $this->postJson('/api/orders', [
            'email' => 'taro@example.com',
            'sku' => 'APPLE-001',
            'quantity' => 2,
        ]);

        $response->assertCreated()
            ->assertJsonPath('email', 'taro@example.com')
            ->assertJsonPath('quantity', 2);

        $this->assertDatabaseHas('orders', [
            'email' => 'taro@example.com',
            'sku' => 'APPLE-001',
            'quantity' => 2,
        ]);
    }

    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);
    }
}

$this->postJson() は、APIにJSON形式でPOSTリクエストを送るメソッドです。実際にサーバーを立ち上げるのではなく、Laravelアプリの中でリクエストを処理するので、ブラウザもネットワークも使わずに速く実行できます。

返ってきたレスポンスには、Laravel独自のアサーションが使えます。よく使うものをまとめました。

アサーション確かめること
assertOk()ステータスコードが200
assertCreated()ステータスコードが201(登録成功)
assertUnprocessable()ステータスコードが422(入力エラー)
assertJsonPath('キー', 値)JSONの指定した位置の値が一致する(型も比較)
assertJsonCount(件数)JSONの配列の件数
assertJsonValidationErrors(['項目'])指定した項目に入力エラーがある
$this->assertDatabaseHas('テーブル', [...])DBに、条件に合う行がある
$this->assertDatabaseCount('テーブル', 件数)DBのテーブルの行数

1つ目のテストでは、レスポンスの中身を確かめたうえで、assertDatabaseHas() でDBに本当に保存されたかも確かめています。第6回で「保存したものを取り出して確かめる」とした考え方と同じです。

2つ目のテストでは、エラーが返ることに加えて、assertDatabaseCount('orders', 0) で何も保存されていないことも確かめています。エラーを返しつつ、裏でデータが保存されてしまう、というバグを防ぐためです。

RefreshDatabase が、DBの準備と後片付けを引き受ける

テストクラスの先頭にある use RefreshDatabase; に注目してください。これがLaravel版の「DBの準備と後片付け」です。

試しに、この1行をコメントアウトして実行してみます。

   FAIL  Tests\Feature\OrderApiTest
  ⨯ 注文を登録できる                                                     0.13s
  ⨯ 数量が0なら登録できない                                              0.01s
  ────────────────────────────────────────────────────────────────────────────
   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:

PDOException: SQLSTATE[HY000]: General error: 1 no such table: orders

「orders テーブルがない」というエラーです。テストではインメモリDBを使うので、誰かがテーブルを作らない限り、DBは空っぽなのです。

RefreshDatabase は、次の2つを自動でやってくれます。

  1. テストの実行前に、マイグレーションを実行してテーブルを作る
  2. 各テストをトランザクションの中で実行し、終わったらロールバックする

2つ目は、第6回で beginTransaction() と rollBack() を使って手作りした仕組みそのものです。インメモリDBでも、MySQLのような残るDBでも、テスト同士が干渉しません。

開発用のDB(database/database.sqlite)にも orders テーブルを作るには、php artisan migrate を実行します。テストの方は RefreshDatabase がテスト用DBにテーブルを作るので、このコマンドを実行していなくても動きます。

テストがバリデーション漏れを見つける

テストの効果を確かめるため、コントローラの数量のルールから min:1 を消してみます。

   FAIL  Tests\Feature\OrderApiTest
  ⨯ 数量が0なら登録できない                                              0.19s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\OrderApiTest > 数量が0なら登録できない
  Expected response status code [422] but received 201.
Failed asserting that 201 is identical to 422.

  at tests/Feature/OrderApiTest.php:40
     36▕             'sku' => 'APPLE-001',
     37▕             'quantity' => 0,
     38▕         ]);
     39▕
  ➜  40▕         $response->assertUnprocessable()
     41▕             ->assertJsonValidationErrors(['quantity']);

【スクショ:php artisan test で、失敗した行に矢印が付いて表示されている実行結果】

「422(入力エラー)のはずが、201(登録成功)が返ってきた」、つまり数量0の注文が登録されてしまったとわかります。php artisan test では、失敗したテストコードの該当行に ➜ の印が付いて表示されるので、どこで失敗したかが一目でわかります。

Factory でテストデータを用意する

絞り込みのテストには、DBにあらかじめ注文が入っている必要があります。そのためのテストデータを作るのがFactoryです。

Factory に「ありふれた値」を定義する

make:model のときに作られた database/factories/OrderFactory.php の definition() に、各項目のデフォルト値を書きます。

public function definition(): array
{
    return [
        'email' => fake()->safeEmail(),
        'sku' => fake()->randomElement(['APPLE-001', 'ORANGE-001', 'GRAPE-001']),
        'quantity' => fake()->numberBetween(1, 5),
    ];
}

fake() は、それらしいダミーデータを作ってくれる「Faker」というライブラリの機能です。safeEmail() なら、example.com などの実在しないドメインのメールアドレスを、ランダムに作ってくれます。

テストで使う

use App\Models\Order;

// ...

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

Order::factory()->create([...]) は、definition() のデフォルト値をもとに注文を1件作り、DBに保存します。引数の配列で指定した項目だけが上書きされます。

第6回の saveOrder() ヘルパーと見比べてみてください。「関心のある項目だけを指定し、残りはデフォルト値に任せる」という考え方は、まったく同じです。Laravelでは、それがモデルごとに用意できる仕組みになっているわけです。

assertJsonPath('0.sku', ...) の 0.sku は、「JSON配列の0番目の要素の sku」という意味です。ドット区切りで、入れ子になったJSONの奥まで指定できます。

同じデータを何件も作るなら、Order::factory()->count(3)->create(['email' => 'taro@example.com']) のように count() で件数を指定できます。

   PASS  Tests\Feature\OrderApiTest
  ✓ 注文を登録できる                                                     0.17s
  ✓ 数量が0なら登録できない                                              0.01s
  ✓ メールアドレスで注文を絞り込める                                     0.02s

第1部との対応表

この回で登場したLaravelの機能と、第1部で手作りした仕組みの対応をまとめます。

やりたいこと第1部(素のPHP)Laravel
テストではインメモリDBを使うnew PDO('sqlite::memory:')(第6回)phpunit.xml の DB_CONNECTION と DB_DATABASE
テーブルを作るschema.sql を exec()(第6回)マイグレーション(RefreshDatabase が自動実行)
テスト後にデータを取り消すbeginTransaction() と rollBack()(第6回)RefreshDatabase
テストデータを楽に作るデフォルト引数つきの saveOrder()(第6回)Factory
保存されたかを確かめるfind() で取り出して比較(第6回)assertDatabaseHas()
ロジックだけのテストPHPUnit\Framework\TestCase(第2〜4回)tests/Unit(同じクラスを継承)

Laravelの機能は便利ですが、中でやっていることは第1部と同じです。仕組みを知っていれば、テストがうまく動かないときにも「どこで何が起きているか」を見当づけられます。

今回の完成コード

今回作成・変更したファイルは次のとおりです。

shop/
├── app/
│   ├── Cart/
│   │   └── Cart.php                      ← 第3回からコピー
│   ├── Http/Controllers/
│   │   └── OrderController.php           ← 追加
│   └── Models/
│       └── Order.php                     ← 追加
├── bootstrap/
│   └── app.php                           ← api: の1行を追加
├── database/
│   ├── factories/
│   │   └── OrderFactory.php              ← 追加
│   └── migrations/
│       └── 2026_09_26_135627_create_orders_table.php  ← 追加
├── routes/
│   └── api.php                           ← 追加
└── tests/
    ├── Feature/
    │   └── OrderApiTest.php              ← 追加
    └── Unit/
        └── CartTest.php                  ← 第3回からコピー

テストクラス以外は、本文に掲載したコードが完成形です。OrderApiTest.php は本文で分けて紹介したので、全体をまとめて載せておきます。

tests/Feature/OrderApiTest.php

<?php

namespace Tests\Feature;

use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_注文を登録できる(): void
    {
        $response = $this->postJson('/api/orders', [
            'email' => 'taro@example.com',
            'sku' => 'APPLE-001',
            'quantity' => 2,
        ]);

        $response->assertCreated()
            ->assertJsonPath('email', 'taro@example.com')
            ->assertJsonPath('quantity', 2);

        $this->assertDatabaseHas('orders', [
            'email' => 'taro@example.com',
            'sku' => 'APPLE-001',
            'quantity' => 2,
        ]);
    }

    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);
    }

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

サンプルのテストを削除したうえで、プロジェクト全体のテストを実行した結果です。

   PASS  Tests\Unit\CartTest
  ✓ 空のカートの合計金額は0円
  ✓ 数量を指定すると単価×数量が合計金額になる
  ✓ 単価がマイナスなら例外を投げる
  ✓ 数量が0なら例外を投げる
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "0円は0円"
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "端数が出ない"
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "端数0.5は切り捨て"
  ✓ 税込金額は10パーセントを加えて1円未満を切り捨てる with data set "端数0.9も切り捨て"

   PASS  Tests\Feature\OrderApiTest
  ✓ 注文を登録できる                                                     0.17s
  ✓ 数量が0なら登録できない                                              0.01s
  ✓ メールアドレスで注文を絞り込める                                     0.02s

  Tests:    11 passed (23 assertions)
  Duration: 0.27s

【スクショ:php artisan test で Unit と Feature のテストがすべて PASS になった実行結果】

まとめ

  • Laravelの phpunit.xml には、インメモリDBなどテスト用の設定が最初からそろっている
  • tests/Unit はLaravelを起動しない普通のユニットテスト、tests/Feature はLaravelを起動してDBやHTTPを使うテスト
  • postJson() や getJson() でAPIにリクエストを送り、assertCreated() や assertJsonPath() で検証する
  • RefreshDatabase が、テーブルの作成とテストごとのロールバックを引き受ける
  • Factoryで、関心のある項目だけを指定してテストデータを作れる
  • どれも、第1部で手作りした仕組みと同じ考え方でできている

次回予告

第8回は「Laravelのフェイク機能とPest」です。第5回ではメール送信をモックで確かめましたが、Laravelには Mail::fake() や Queue::fake()、外部APIへの通信を差し替える Http::fake() といった、専用のフェイク機能が用意されています。後半では、テストをより短く書けるテストフレームワーク「Pest」を紹介し、同じテストをPHPUnit版とPest版で見比べます。

参考資料

B!
← 一覧へ戻る