この記事は、映画レビューアプリのLaravel移植版で、Laravel Breeze標準のメール認証を実装する過程で学んだFeature Testの書き方を、自分の理解を定着させるために整理した学習メモです。
現時点では「メール未認証ユーザーがレビューを投稿できないこと」と「メール未認証ユーザーがレビュー返信を投稿できないこと」の2つのテストまでを扱います。続きは実装を進めながら追記します。
テストメソッド名に使われる英単語や命名の読み方は、関連記事「PHPUnit・Laravel Featureテストでよく使う英単語集」にまとめています。
- 環境
- 今回テストしたいこと
- Feature Testの基本は Arrange / Act / Assert
- 1つ目のテスト:メール未認証ユーザーはレビューを投稿できない
- 今回覚えたLaravelテスト用メソッド
- 2つ目のテスト:メール未認証ユーザーはレビュー返信を投稿できない
- 2つのテストから見えてきた共通パターン
- 今回のまとめ
- 次回追記予定
- 参考資料
- 関連記事
環境
- PHP 8.4
- Laravel 13
- Laravel Breeze
- PHPUnit
- Laravel Sail
- MySQL
今回のアプリでは、Laravel Breeze標準のメール認証を有効化し、メール未認証ユーザーからの新規レビュー投稿を制限する実装を進めています。
※今回紹介するテストクラスでは
RefreshDatabaseを使用しています。各テストを独立したDB状態で実行するための仕組みですが、詳しい役割は今後追記します。
Laravel公式ドキュメントでは、Feature Testは複数のオブジェクトの連携やHTTPリクエストを含む、アプリケーションの比較的大きな範囲をテストするものとして説明されています。
参考:
今回テストしたいこと
メール認証を有効にしたことで、レビュー投稿ルートには次の2条件が必要になりました。
ログイン済み
+
メール認証済み
つまり、メール未認証ユーザーがレビュー投稿を試みた場合は、
メール未認証ユーザー
↓
レビューをPOST
↓
メール認証案内画面へリダイレクト
↓
reviewsテーブルには保存されない
となることをテストします。
単に「画面がリダイレクトされた」だけではなく、DBへレビューが保存されていないことまで確認するのがポイントです。
Feature Testの基本は Arrange / Act / Assert
テストを書くときは、まず次の3段階に分けると理解しやすくなりました。
Arrange
テストに必要なデータや状態を準備する
Act
実際の操作を実行する
Assert
結果が期待どおりか確認する
今回のテストへ当てはめると、
Arrange
├─ メール未認証ユーザーを作成
└─ レビュー対象の作品を作成
Act
└─ 未認証ユーザーとしてレビュー投稿をPOST
Assert
├─ メール認証案内画面へリダイレクトされる
└─ reviewsテーブルが0件のままである
となります。
1つ目のテスト:メール未認証ユーザーはレビューを投稿できない
1. テストメソッドを作る
まずテストの目的が分かる名前を付けます。
public function test_unverified_user_cannot_store_review(): void
{
}
名前を日本語にすると、
メール未認証ユーザーはレビューを保存できない
という意味です。
2. メール未認証ユーザーを作る
$user = User::factory()->unverified()->create();
分解すると、
User::factory()
↓
Userのテストデータを作るFactoryを取得
unverified()
↓
メール未認証状態にする
create()
↓
実際にテストDBへ保存
となります。
通常の、
User::factory()->create();
はこのプロジェクトではメール認証済みユーザーを作成します。
一方、
User::factory()->unverified()->create();
とすると、email_verified_atがnullのメール未認証ユーザーを作成できます。
3. 投稿対象の作品を作る
$item = Item::factory()->create();
レビューを投稿するには対象作品が必要なので、テスト用の作品を1件DBへ作成します。
ここまでがArrangeです。
$user = User::factory()->unverified()->create();
$item = Item::factory()->create();
4. actingAs()でログイン状態を作る
$this->actingAs($user)
actingAsは、
$userとして振る舞う
ためのLaravelのテスト用メソッドです。
つまり、
ログインは成功している
しかしメール認証は終わっていない
という状態を作っています。
5. post()でレビュー投稿を再現する
$response = $this
->actingAs($user)
->post(route('reviews.store', $item), [
'rating' => 5,
'body' => '未認証ユーザーのレビューです。',
]);
LaravelのHTTP Testでは、get()、post()、put()、patch()、delete()などを使ってアプリケーション内部でHTTPリクエストを再現できます。
Laravel公式ドキュメントによると、これらは実際に外部ネットワークへHTTP通信するのではなく、Laravel内部でリクエストをシミュレートします。
route('reviews.store', $item)とは
route('reviews.store', $item)
は、名前付きルートreviews.storeのURLを生成しています。
今回のルートは作品IDを必要とするため、$itemも渡しています。
6. なぜ正常な投稿データを送るのか
今回送る値は、
[
'rating' => 5,
'body' => '未認証ユーザーのレビューです。',
]
と、あえて正常な値にしています。
本文を空にすると、
メール未認証だから拒否された?
それとも
本文のバリデーションエラーだから拒否された?
と原因が混ざってしまいます。
今回確認したいのはメール認証によるアクセス制御だけなので、メール認証以外の条件は正常にしておきます。
1つのテストでは、できるだけ確認したい原因を1つに絞る。
7. assertRedirect()で認証画面への遷移を確認する
$response->assertRedirect(route('verification.notice'));
assertRedirect()は、
HTTPレスポンスのリダイレクト先が期待したURLか
を検証するAssertionです。
今回期待しているのはメール認証案内画面です。
8. assertDatabaseCount()で「保存されていない」ことも確認する
$this->assertDatabaseCount('reviews', 0);
Laravel公式ではassertDatabaseCount()は、指定したテーブルが期待した件数のレコードを持つことを確認するDatabase Assertionとして提供されています。
今回は、
reviewsテーブルには1件も保存されていない
ことを保証します。
アクセス制御のテストでは、
アクセスを拒否した
+
副作用も発生していない
の両方を見ることが大切です。
完成したテスト
public function test_unverified_user_cannot_store_review(): void
{
$user = User::factory()->unverified()->create();
$item = Item::factory()->create();
$response = $this
->actingAs($user)
->post(route('reviews.store', $item), [
'rating' => 5,
'body' => '未認証ユーザーのレビューです。',
]);
$response->assertRedirect(route('verification.notice'));
$this->assertDatabaseCount('reviews', 0);
}
構造を整理すると、
Arrange
$user = ...
$item = ...
Act
$response = ...
Assert
$response->assertRedirect(...)
$this->assertDatabaseCount(...)
となっています。
Whyを残すPHPDoc
/**
* メール未認証ユーザーからの公開レビュー投稿を防ぐため、
* 認証案内画面へリダイレクトされ、レビューが保存されないことを保証する。
*/
public function test_unverified_user_cannot_store_review(): void
{
// ...
}
単に「未認証ユーザーのテスト」と書くのではなく、なぜこのテストが必要なのかまで残します。
テストを1件だけ実行する
sail artisan test tests/Feature/ReviewTest.php \
--filter=unverified_user_cannot_store_review
今回の実行結果:
PASS Tests\Feature\ReviewTest
✓ unverified user cannot store review
Tests: 1 passed (3 assertions)
--filterを使うと、指定したテストだけを実行できます。
「1 test」と「assertions」の違い
Test
= 1つのシナリオ
Assertion
= そのシナリオの結果を確かめる個々の検証
と分けて考えると理解しやすくなります。
既存の「未ログイン」テストとの違い
既存の未ログインテストは、
未ログイン
↓
レビュー投稿
↓
ログイン画面へ
を確認します。
今回のテストは、
ログイン済み
+
メール未認証
↓
レビュー投稿
↓
メール認証案内へ
を確認します。
| ユーザー状態 | 期待する結果 |
|---|---|
| 未ログイン | ログイン画面へ |
| ログイン済み・メール未認証 | メール認証案内へ |
| ログイン済み・メール認証済み | レビュー投稿可能 |
今回覚えたLaravelテスト用メソッド
| メソッド | 今回の理解 |
|---|---|
User::factory() |
Userのテストデータを作る準備 |
unverified() |
メール未認証状態にするFactory state |
create() |
テストDBへ実際にレコードを作成 |
actingAs($user) |
$userとしてログインした状態にする |
post() |
POSTリクエストをLaravel内部で再現する |
route() |
名前付きルートからURLを生成する |
assertRedirect() |
リダイレクト先を検証する |
assertDatabaseCount() |
DBテーブルのレコード件数を検証する |
2つ目のテスト:メール未認証ユーザーはレビュー返信を投稿できない
次は、レビュー投稿と同じ考え方をレビュー返信にも適用します。
今回確認したい流れは次のとおりです。
メール未認証ユーザー
↓
レビュー返信をPOST
↓
メール認証案内画面へリダイレクト
↓
review_commentsテーブルには保存されない
レビュー投稿のテストと構造がほぼ同じなので、Arrange / Act / Assertの復習にもなります。
テストメソッドを作る
public function test_unverified_user_cannot_store_review_comment(): void
{
}
意味は「メール未認証ユーザーはレビュー返信を保存できない」です。
Arrange:未認証ユーザーと返信対象レビューを作る
$user = User::factory()->unverified()->create();
$review = Review::factory()->create();
1行目でメール未認証ユーザーを、2行目で返信先になるレビューを1件作ります。
メール未認証ユーザー
+
返信対象レビュー
という前提が整います。
Act:未認証ユーザーとしてレビュー返信をPOSTする
$response = $this
->actingAs($user)
->post(route('reviews.comments.store', $review), [
'body' => '未認証ユーザーのレビュー返信です。',
'form_review_id' => $review->id,
]);
actingAs($user)は、$userとしてログインした状態にするメソッドです。
route('reviews.comments.store', $review)では、返信対象のレビューをルートパラメータとして渡し、返信投稿先URLを生成します。
form_review_idは何のためにあるのか
'form_review_id' => $review->id,
は、複数の返信フォームがある作品詳細画面で「どのフォームから送信されたか」を識別するための値です。
このアプリでは、本当の返信先レビューはURL上の$reviewから決めます。リクエスト本文のreview_idを返信先の正本として信用しません。
URL上の $review
↓
本当の返信先
form_review_id
↓
送信元フォームの識別
review_id
↓
返信先決定の正本にはしない
テストでも実際のフォームと同じ正常なリクエストを再現するため、form_review_idへ正しい値を渡します。
なぜ今回も正常な本文を送るのか
'body' => '未認証ユーザーのレビュー返信です。',
と正常な本文を送ります。
本文を空にすると「メール未認証で拒否されたのか」「本文バリデーションで拒否されたのか」が混ざるためです。今回の目的はverifiedミドルウェアによるアクセス制御だけなので、その他の入力条件は正常にします。
Assert:メール認証案内画面へのリダイレクトを確認する
$response->assertRedirect(route('verification.notice'));
これで、ログイン済みでもメール未認証なら返信投稿を実行できず、メール認証案内画面へ送られることを確認します。
Assert:返信がDBへ保存されていないことを確認する
$this->assertDatabaseCount('review_comments', 0);
レビュー返信の保存先はreview_commentsテーブルです。
ここでも、
アクセス拒否
+
副作用なし
の両方を確認しています。
完成した2つ目のテスト
public function test_unverified_user_cannot_store_review_comment(): void
{
$user = User::factory()->unverified()->create();
$review = Review::factory()->create();
$response = $this
->actingAs($user)
->post(route('reviews.comments.store', $review), [
'body' => '未認証ユーザーのレビュー返信です。',
'form_review_id' => $review->id,
]);
$response->assertRedirect(route('verification.notice'));
$this->assertDatabaseCount('review_comments', 0);
}
Arrange / Act / Assertで見ると、
Arrange
$user = ...
$review = ...
Act
$response = ...
Assert
$response->assertRedirect(...)
$this->assertDatabaseCount(...)
となり、1つ目のレビュー投稿テストとほぼ同じ構造です。
Whyを残すPHPDoc
/**
* メール未認証ユーザーからの公開レビュー返信を防ぐため、
* 認証案内画面へリダイレクトされ、返信が保存されないことを保証する。
*/
単に何をテストしているかだけでなく、なぜ必要なのかまで残します。
このテストだけを実行する
sail artisan test tests/Feature/ReviewCommentStoreTest.php \
--filter=unverified_user_cannot_store_review_comment
実行結果:
PASS Tests\Feature\ReviewCommentStoreTest
✓ unverified user cannot store review comment
Tests: 1 passed (3 assertions)
これで、
メール未認証ユーザー
├─ レビュー投稿できない
└─ レビュー返信できない
という2つのアクセス制御をFeature Testで確認できました。
2つのテストから見えてきた共通パターン
今回の2つのテストは、対象テーブルやルートが違うだけで基本構造は同じでした。
Arrange
未認証ユーザー
+
操作対象データ
Act
actingAs()
+
post()
Assert
assertRedirect()
+
assertDatabaseCount()
拒否されたことだけでなく、DBに副作用が残っていないこともセットで確認するのが共通ポイントです。
今回のまとめ
Arrange
必要な状態を作る
Act
ユーザー操作を再現する
Assert
画面上の結果とDB上の結果を確認する
アクセス制御をテストするときは、
「拒否されたこと」
+
「DBに副作用が残っていないこと」
まで確認します。
また、メール認証だけを検証したい場合は投稿内容を正常値にして、バリデーションなど別の失敗要因を混ぜないようにします。
次回追記予定
- 会員登録時に認証メールが送信されたことを確認するテスト
-
Notification::fake()の意味 -
Notification::assertSentTo()の使い方 - メール認証済み / 未認証状態のテスト
- 署名付き認証URLのテスト
-
RefreshDatabaseの役割 - Feature Test全体を実行して回帰を確認する方法