テスト — Pest で API の契約を固定する
ここまでで、商品一覧と注文作成が動くようになりました。この章でテストを書きます。
テストを書く目的は「バグを見つけること」だけではありません。いま動いている振る舞いを固定して、あとから壊したときに気づけるようにすることが主目的です。この連載はこれから 12 章ぶんの機能を足していきます。足すたびに既存の動作が壊れていないかを手で確認するのは現実的ではありません。
なぜ API はテストしやすいのか
画面のあるアプリケーションと比べると、API のテストは書きやすい部類です。
入力と出力が明確です。 HTTP メソッド・URL・リクエストボディを与えると、ステータスコードとレスポンスボディが返ります。ブラウザを起動する必要も、画面の要素を探す必要もありません。
判定が機械的です。 「ボタンが青いか」ではなく「ステータスが 201 か」「data.id に値があるか」を見ます。曖昧さが入りません。
第4章で決めたステータスコード、第7章で決めたレスポンスの形、第9章で決めたエラーの形。これらはすべてクライアントとの契約でした。テストはその契約をコードで書いたものです。
Pest の書き方
Laravel の tests ディレクトリには、Pest または PHPUnit のサンプルテストが最初から入っています1。どちらの記法で書くかを選べますが、公式ドキュメントのコード例は Pest を先に示すので、この連載も Pest を使います。
基本の形はこれだけです。
<?php
test('商品一覧を取得できる', function () {
$response = $this->getJson('/api/v1/products');
$response->assertStatus(200);
});
クラスもメソッドもありません。test() に名前と処理を渡すだけです。テスト名を日本語で書けるので、何を確認しているかが読んで分かります。
実行します。
php artisan test
it() という書き方もあります。意味は同じで、英語で読んだときに文になるよう設計されています。
it('returns a list of products', function () {
// ...
});
この連載では日本語の test() で統一します。
前処理をまとめる
複数のテストで同じ準備が要るときは beforeEach() にまとめます。
beforeEach(function () {
Product::factory()->count(30)->has(Stock::factory())->create();
});
test('商品一覧を取得できる', function () {
// 30 件が用意された状態で始まる
});
最初のテスト
第8章で作った一覧 API をテストします。
<?php
use App\Models\Product;
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('商品一覧が20件ずつ返る', function () {
Product::factory()->count(30)->has(Stock::factory())->create();
$response = $this->getJson('/api/v1/products');
$response
->assertStatus(200)
->assertJsonCount(20, 'data')
->assertJsonPath('meta.total', 30)
->assertJsonPath('meta.per_page', 20);
});
3 つの要素があります。
pest()->use(RefreshDatabase::class) — テストごとにデータベースを元に戻します2。前のテストが作ったデータが次のテストに残らないので、テストの実行順に依存しなくなります。
ファクトリでデータを用意する — 第3章で作ったファクトリをここで使います。テストの中で INSERT 文を書く必要がありません。
assertJsonPath で中身を確認する — meta.total のようにドット記法で階層をたどれます3。第7章で data ラッピングを残した構造が、そのままパスになります。
使えるアサーション
よく使うものを挙げます。
| メソッド | 確認すること |
|---|---|
assertStatus(200) | ステータスコード |
assertOk() / assertCreated() / assertNoContent() | 200 / 201 / 204 の短縮形 |
assertJsonPath('data.0.name', 'コーヒー豆') | 指定パスの値 |
assertJsonCount(20, 'data') | 配列の件数 |
assertJsonStructure(['data' => [['id', 'name']]]) | キーの構造 (値は見ない) |
assertJsonValidationErrors(['customer_email']) | 422 のときのエラー対象 |
getJson / postJson / putJson / patchJson / deleteJson で各メソッドを送れます3。
書き込みのテスト
注文作成をテストします。ここからは、レスポンスだけでなくデータベースの状態も確認します。
<?php
use App\Models\Product;
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('注文を作成できる', function () {
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 3],
],
]);
$response
->assertStatus(201)
->assertJsonPath('data.total_amount', 3600);
$this->assertDatabaseHas('orders', [
'customer_email' => 'taro.test@example.com',
'total_amount' => 3600,
]);
$this->assertDatabaseHas('order_items', [
'product_id' => $product->id,
'quantity' => 3,
'unit_price' => 1200,
]);
});
assertDatabaseHas は、指定した条件に合う行があるかを確認します2。レスポンスだけを見ていると、「201 を返したが保存されていない」を見逃します。
合計金額を 3600 と明示しているのも意図があります。1200 × 3 を計算した値を書くのではなく、期待する結果を直接書きます。テストの中で計算すると、実装と同じ間違いをしたときに気づけません。
在庫が減っていることも確認する
注文が作られただけでは足りません。
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 7, // 10 - 3
]);
副作用まで含めて固定します。第14章でトランザクションを入れるとき、このテストが「在庫の減算が消えていないか」の検出器になります。
失敗パスを固定する
正常系だけのテストは、半分しか守っていません。
第5章と第9章で決めたエラーの振る舞いも、契約の一部です。
test('メールアドレスが無いと422になる', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);
$response
->assertStatus(422)
->assertJsonValidationErrors(['customer_email']);
});
test('在庫が足りないと409になる', function () {
$product = Product::factory()->create();
Stock::factory()->soldOut()->create(['product_id' => $product->id]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);
$response
->assertStatus(409)
->assertJsonPath('code', 'INSUFFICIENT_STOCK');
$this->assertDatabaseCount('orders', 0);
});
3 つ確認しています。ステータスコードが 409 であること、code が INSUFFICIENT_STOCK であること、そして注文が 1 件も作られていないことです。
最後のものが重要です。エラーを返しても、途中まで処理が進んで中途半端なデータが残っていることがあります。第14章でトランザクションを入れるまで、この状態は実際に起こりえます。テストで固定しておけば、直したときに green になり、壊したときに red になります。
第3章で作った soldOut() state がここで効いています。quantity を 0 にする処理を書く代わりに、意図を名前で表せています。
レスポンスの形が変わっていないことも守る
test('注文作成のレスポンス構造が変わっていない', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);
$response->assertJsonStructure([
'data' => [
'id',
'customer_email',
'status',
'total_amount',
'ordered_at',
],
]);
});
assertJsonStructure は値でなくキーの構成を見ます。第7章で「フィールドは足せるが消せない」と書きました。消したときにこのテストが落ちます。
items を確認していないことに気づいたでしょうか。第7章の OrderResource は whenLoaded('items') を使っているので、リレーションを読み込んでいなければキーごと消えます。注文作成の直後は読み込まれていない状態なので、items は返りません。
明細まで含めて返したいなら、コントローラで読み込んでから Resource に渡します。
return (new OrderResource($order->load('items')))
->response()
->setStatusCode(201);
この load() を足すかどうかは、クライアントが作成直後に明細を必要とするかで決めます。必要ないなら足しません。 返さないほうがレスポンスが軽く、クエリも 1 回減ります。
N+1 が起きていないことをテストで守る
第8章で Model::preventLazyLoading(! $this->app->isProduction()) を設定しました。テスト環境は本番ではないので、この設定が有効です。
つまり、with() を書き忘れたエンドポイントのテストは例外で落ちます。
test('商品一覧で N+1 が起きていない', function () {
Product::factory()->count(30)->has(Stock::factory())->create();
// ProductResource が stock を参照するので、
// コントローラで with('stock') を忘れていればここで例外になる
$this->getJson('/api/v1/products')->assertStatus(200);
});
専用のテストを書かなくても、一覧を叩く既存のテストがそのまま検出器になります。第8章で「本番以外でだけ有効にする」と決めたのは、この効果を得つつ本番の可用性を保つためです。
この連載でのテストの型
この章以降、各章の末尾に「テスト」の節を置きます。
新しい機能を足すたびに、その章のテストも一緒に書きます。まとめて最後に書くのではありません。理由は 2 つあります。
書いた直後なら、何が正しい振る舞いかを覚えています。3 か月後に「この API はどうあるべきだったか」を思い出すのは困難です。
もう 1 つは、テストしにくいコードに気づけることです。テストを書こうとして「これは書きにくい」と感じたら、たいてい設計に理由があります。第6章でサービスクラスへ切り出したのは、この書きやすさのためでもあります。
各章で扱うテストの道具を先に示しておきます。
| 章 | テストの道具 |
|---|---|
| 第11章 認証 | Sanctum::actingAs() で認証済みの状態を作る |
| 第12章 認可 | 他人のリソースへのアクセスが 403 / 404 になることを固定する |
| 第13章 レート制限 | 制限を超えたときに 429 が返ることを確認する |
| 第14章 トランザクション | 同じ操作を 2 回送っても副作用が 1 回だけであることを固定する |
| 第15章 通知 | Mail::fake() / Notification::fake() で送信を止めて検証する |
| 第16-17章 キュー | Queue::fake() でジョブの投入を確認する |
| 第19章 ストレージ | Storage::fake() で実ファイルを作らずに検証する |
| 第20章 スケジューラ | travelTo() で時刻を固定する |
これらの fake は、対象の機能を学ぶ章で一緒に扱います。fake だけを集めた章を作らないのは、対象を知らずに使い方だけ覚えても定着しないためです。
本番で効く注意点
認可のテストは必ず書く
第12章で扱いますが、先に書いておきます。認可の抜けは、テストを書かないかぎり本番まで気づきません。
正常系のテストは「自分のデータを取得できる」を確認します。これは認可が壊れていても通ります。誰でもアクセスできる状態なら、自分のデータにもアクセスできるからです。
必要なのは「他人のデータにアクセスできない」テストです。
test('他人の注文は取得できない', function () {
// 第12章で書く形
});
第12章では、このテストを各エンドポイントに必ず 1 つ置きます。
外部との通信をテストから出す
テストが外部の API を叩くと、3 つの問題が出ます。遅くなる、相手のサービスの状態でテストが落ちる、相手に迷惑をかける。
Laravel は HTTP クライアントを止める仕組みを持っています。第16章で外部 API を呼ぶジョブを書くとき、Http::fake() を使います。
メール送信も同じです。テストで実際にメールを送ると、fake() を書き忘れた瞬間にテスト用のアドレスへ大量のメールが飛びます。
テストが実行順に依存しないようにする
RefreshDatabase を使えばデータベースは戻りますが、それ以外の状態は残ることがあります。第18章で扱うキャッシュがその例です。
test('人気商品が返る', function () {
// 前のテストがキャッシュに入れた古い結果を読む可能性がある
});
第18章では、キャッシュを使うテストの前処理でキャッシュを消します。「単体で実行すると通るが、全体で実行すると落ちる」テストは、この種の状態の残留が原因です。
テストを消して green にしない
機能を変更してテストが落ちたとき、2 つの対応があります。テストを直すか、実装を直すかです。
テストが落ちたということは、以前決めた振る舞いが変わったということです。それが意図した変更なら、テストを新しい期待に合わせて書き換えます。意図していないなら、実装のほうが間違っています。
判断せずにテストを消すのが最悪の選択です。落ちたテストが守っていた振る舞いは、そのまま誰も守らなくなります。
まとめ
- テストの主目的はバグ探しでなく、いまの振る舞いを固定してあとから壊したときに気づくこと
- API は入力と出力が明確なので、テストが書きやすい。契約をコードで書いたものがテスト
pest()->use(RefreshDatabase::class)でテストごとにデータベースを戻す- レスポンスだけでなく
assertDatabaseHasで保存結果も確認する。「201 を返したが保存されていない」を見逃さない - 正常系だけでは半分。エラー時のステータス・
code・データが作られていないことも固定する - 期待値は計算せずに直接書く。実装と同じ間違いをしたときに気づけなくなる
- この章以降、各章の末尾にテストの節を置く
次に読む
ここまでで、読み取りと書き込みの両方が動き、テストで固定されました。ただし誰でも叩ける状態です。次章 認証 — Sanctum によるトークン認証 から認証と認可を入れます。会員登録とログインを作り、注文をゲストのものから会員のものへ移します。この章で書いたテストも、認証が入ったことで書き換えが必要になります。その手順も次章で扱います。
練習問題
次のテストは、実装が壊れていても通ってしまいます。理由を説明してください
test('注文を作成できる', function () {
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 3],
],
]);
$response->assertStatus(201);
});
解答例
201 を返すことしか確認していません。
次のような実装でもこのテストは通ります。
- 注文レコードを作らずに 201 を返す
- 合計金額を 0 円で保存する
- 明細を作らない
- 在庫を減らさない
どれも「注文を作成できた」とは言えませんが、テストは green のままです。
必要な確認を足します。
$response
->assertStatus(201)
->assertJsonPath('data.total_amount', 3600);
$this->assertDatabaseHas('orders', ['total_amount' => 3600]);
$this->assertDatabaseHas('order_items', ['quantity' => 3, 'unit_price' => 1200]);
$this->assertDatabaseHas('stocks', ['product_id' => $product->id, 'quantity' => 7]);
ステータスコードは「処理が最後まで到達したか」しか示しません。 何が起きたかは、レスポンスの中身とデータベースの状態で確認します。
とくに在庫の減算は忘れやすい箇所です。注文が作られていれば目的は達したように見えますが、在庫が減っていなければ二重に売れます。
「テストを書く時間がないので、リリース後に書く」という判断にどう応じますか
解答例
この判断で失われるのは時間ではなく、書ける内容です。
リリース後に書くテストは、「いま動いている振る舞い」を写し取ったものになります。実装を見ながら、その通りに動くことを確認するテストです。実装にバグがあれば、バグごと固定します。
書く順序が違うと、テストの意味が変わります。
- 実装の前か直後に書く → 「こう動くべき」を書く。実装が違えば落ちる
- 3 か月後に書く → 「こう動いている」を書く。実装が間違っていても通る
後者でも「今後の変更で壊れたら気づける」効果はあるので、まったく無意味ではありません。それでも、本来の目的の半分しか果たしません。
現実的な落としどころを 2 つ挙げます。
失敗パスだけ先に書く: 全部は無理でも、エラー時の振る舞い (422 / 409 / 403) だけは先に書きます。この部分は手で確認しにくく、リリース後に「そういえば確認していない」となりやすい箇所です。
壊れると困る順に書く: 決済・在庫・認可のように、壊れたときの被害が大きいものから書きます。商品一覧の並び順が違っても復旧できますが、二重課金は復旧に手間がかかります。
「時間がない」という制約自体は現実なので、ゼロか全部かでなく、優先順位を付ける話として扱うのが建設的です。