👻

弊記事であるLaravel12 ブートストラップ: Chirper を題材に Laravel 機能テストの基礎を固める(1) 既存のtest

に公開

https://zenn.dev/catatsumuri/books/355577cde4961a

さて、上記のような本があって、これはChirperというつぶやきサイトをLaravel12 + Inertia.js (w/React)で作るあり元は bootstrap.laravel.com (消失済み) を元にした翻訳+加筆である。これをベースとした拡張記事を書いていくが、まずはテストについて何回かに分けて書いてみる。

Laravelの「テスト」は主に2つに大別される

さて、cloneしてきたとしたらば、その tests/ ディレクトリを眺めてみると

$ ls tests/
Feature  TestCase.php  Unit

このようにFeature/ とUnit/ という2つのディレクトリが見えるはずだ。ここでTestCase.phpというのは既定classであり今現在は空になっている

TestCase.php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    //
}

ここでUnitに置くのがユニットテストであるが本稿では取り扱わない。

まずは既存のテストを眺めてみよう

スターターキットが自動的に置いていったテストが結構ある。これをまず確認してみよう。

$ tree tests/Feature/
tests/Feature/
├── Auth
│   ├── AuthenticationTest.php
│   ├── EmailVerificationTest.php
│   ├── PasswordConfirmationTest.php
│   ├── PasswordResetTest.php
│   ├── RegistrationTest.php
│   ├── TwoFactorChallengeTest.php
│   └── VerificationNotificationTest.php
├── DashboardTest.php
└── Settings
    ├── PasswordUpdateTest.php
    ├── ProfileUpdateTest.php
    └── TwoFactorAuthenticationTest.php

3 directories, 11 files

これらは「スターターキットを展開した直後の状態」が正常かどうかを担保するテストである。
したがって、

  • すべて通るなら「ひとまず環境は正常」
  • どれか 1 つでも落ちるなら「そもそも環境か設定が壊れている」

という判断基準になる。

この時点でテストが落ちているなら、原因を理解して直すまでアプリ開発を進めるべきではない。 ここが Laravel の機能テストを書くうえでの前提になる。

ここで、まず、全てのテストを行うには以下のように実行する

./vendor/bin/sail test

これを行うと

$ ./vendor/bin/sail test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true                                                    0.28s

   PASS  Tests\Feature\Auth\AuthenticationTest
  ✓ login screen can be rendered                                         0.05s
  ✓ users can authenticate using the login screen                        0.06s
  ✓ users with two factor enabled are redirected to two factor challeng… 0.04s
  ✓ users can not authenticate with invalid password                     0.04s
  ✓ users can logout                                                     0.03s
  ✓ users are rate limited                                               0.03s

   PASS  Tests\Feature\Auth\EmailVerificationTest
  ✓ email verification screen can be rendered                            0.04s
  ✓ email can be verified                                                0.04s
  ✓ email is not verified with invalid hash                              0.03s
  ✓ email is not verified with invalid user id                           0.03s
  ✓ verified user is redirected to dashboard from verification prompt    0.03s
  ✓ already verified user visiting verification link is redirected with… 0.04s

   PASS  Tests\Feature\Auth\PasswordConfirmationTest
  ✓ confirm password screen can be rendered                              0.04s
  ✓ password confirmation requires authentication                        0.02s

   PASS  Tests\Feature\Auth\PasswordResetTest
  ✓ reset password link screen can be rendered                           0.03s
  ✓ reset password link can be requested                                 0.24s
  ✓ reset password screen can be rendered                                0.24s
  ✓ password can be reset with valid token                               0.24s
  ✓ password cannot be reset with invalid token                          0.23s

   PASS  Tests\Feature\Auth\RegistrationTest
  ✓ registration screen can be rendered                                  0.03s
  ✓ new users can register                                               0.03s

   PASS  Tests\Feature\Auth\TwoFactorChallengeTest
  ✓ two factor challenge redirects to login when not authenticated       0.03s
  ✓ two factor challenge can be rendered                                 0.04s

   PASS  Tests\Feature\Auth\VerificationNotificationTest
  ✓ sends verification notification                                      0.03s
  ✓ does not send verification notification if email is verified         0.03s

   PASS  Tests\Feature\DashboardTest
  ✓ guests are redirected to the login page                              0.03s
  ✓ authenticated users can visit the dashboard                          0.03s

   PASS  Tests\Feature\Settings\PasswordUpdateTest
  ✓ password update page is displayed                                    0.03s
  ✓ password can be updated                                              0.04s
  ✓ correct password must be provided to update password                 0.03s

   PASS  Tests\Feature\Settings\ProfileUpdateTest
  ✓ profile page is displayed                                            0.04s
  ✓ profile information can be updated                                   0.04s
  ✓ email verification status is unchanged when the email address is un… 0.04s
  ✓ user can delete their account                                        0.03s
  ✓ correct password must be provided to delete account                  0.03s

   PASS  Tests\Feature\Settings\TwoFactorAuthenticationTest
  ✓ two factor settings page can be rendered                             0.04s
  ✓ two factor settings page requires password confirmation when enable… 0.03s
  ✓ two factor settings page does not requires password confirmation wh… 0.03s
  ✓ two factor settings page returns forbidden response when two factor… 0.03s

  Tests:    40 passed (128 assertions)
  Duration: 2.53s

このように全てのテストが実行される。ここで全てのテストが通過すればokだ。通過しない場合は先に進んではいけない。原因を自分で把握して直すか、それが無理ならAIに問い合わせるなどして何とかして直す事。

1つだけテストを取り上げてテストの条件がどうなっているか見てみる(ここではDashboardテストを使う)

この記事で全てのテストの全ての項目を解説するのは余りにも辛いの、1つだけ取り上げて詳細に解説する。ここではtests/Feature/DashboardTest.phpを使う。

この記事では、LLM にコメントを付けてもらったコードをそのまま引用して解説する。

tests/Feature/DashboardTest.php
<?php

namespace Tests\Feature;

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

class DashboardTest extends TestCase
{
    // ------------------------------------------------------------
    // RefreshDatabase:
    //   各テストごとにトランザクション or 再マイグレーションを行い、
    //   DB 状態を完全リセットする。
    //   Feature テストでユーザ作成が必要なため必須。
    // ------------------------------------------------------------
    use RefreshDatabase;

    /**
     * ゲスト(未ログインユーザー)がダッシュボードにアクセスした場合、
     * ログインページへリダイレクトされることを確認する。
     *
     * ポイント:
     * - 認可(ミドルウェア: auth)の挙動を検証する典型的 Feature テスト。
     * - `assertRedirect()` で遷移先ルートを明示することで意図がブレない。
     */
    public function test_guests_are_redirected_to_the_login_page()
    {
        // GET /dashboard にアクセス(未ログイン)
        $this->get(route('dashboard'))
            // 認証ミドルウェアにより login へ飛ばされるはず
            ->assertRedirect(route('login'));
    }

    /**
     * 認証済みユーザーがダッシュボードを正常に閲覧できることを確認する。
     *
     * ポイント:
     * - UserFactory で DB にユーザを1件作成
     * - actingAs() でログイン状態をシミュレート
     * - ダッシュボードが 200 OK を返すことが期待値
     */
    public function test_authenticated_users_can_visit_the_dashboard()
    {
        // テスト用のユーザを作成
        $user = User::factory()->create();

        // テスト実行中だけそのユーザでログイン状態にする
        $this->actingAs($user);

        // GET /dashboard にアクセス(ログイン済み)
        $this->get(route('dashboard'))
            ->assertOk();  // HTTP 200 が返ること
    }
}

ここで重要な概念として

    // ------------------------------------------------------------
    // RefreshDatabase:
    //   各テストごとにトランザクション or 再マイグレーションを行い、
    //   DB 状態を完全リセットする。
    //   Feature テストでユーザ作成が必要なため必須。
    // ------------------------------------------------------------
    use RefreshDatabase;

これ。

従ってtestingという完全に消していいデーターベースが必要となるわけだ。(Laravel sailを使わないかつsqliteの場合はインメモリデーターベースなども使われる)

test_guests_are_redirected_to_the_login_pageを見る

    /**
     * ゲスト(未ログインユーザー)がダッシュボードにアクセスした場合、
     * ログインページへリダイレクトされることを確認する。
     *
     * ポイント:
     * - 認可(ミドルウェア: auth)の挙動を検証する典型的 Feature テスト。
     * - `assertRedirect()` で遷移先ルートを明示することで意図がブレない。
     */
    public function test_guests_are_redirected_to_the_login_page()
    {
        // GET /dashboard にアクセス(未ログイン)
        $this->get(route('dashboard'))
            // 認証ミドルウェアにより login へ飛ばされるはず
            ->assertRedirect(route('login'));
    }

コメントの通りである。基本的にログインしていないユーザーが /dashboard とかを直で打ってきても見えない事を保証する。テスト実行時には認証されない状態であるので、route('dashboard')を直GETすれば認証なしで /dashboard にアクセスした事になるというわけだ。

この時は認証せずに /dashboardにアクセスしようとしているため、ログイン画面に引き戻されるのが期待された動作になる。それを

->assertRedirect(route('login'));

で表現している。

続いて、test_authenticated_users_can_visit_the_dashboardを見る

    /**
     * 認証済みユーザーがダッシュボードを正常に閲覧できることを確認する。
     *
     * ポイント:
     * - UserFactory で DB にユーザを1件作成
     * - actingAs() でログイン状態をシミュレート
     * - ダッシュボードが 200 OK を返すことが期待値
     */
    public function test_authenticated_users_can_visit_the_dashboard()
    {
        // テスト用のユーザを作成
        $user = User::factory()->create();

        // テスト実行中だけそのユーザでログイン状態にする
        $this->actingAs($user);

        // GET /dashboard にアクセス(ログイン済み)
        $this->get(route('dashboard'))
            ->assertOk();  // HTTP 200 が返ること
    }

ここではユーザーを1つ作ってそのユーザーに対して「認証済み」の状態を発生させた状態で /dashboardとかをGETするとassertOk()となる事(つまりリダイレクトとかされない)ことを保証している。ここでユーザーを作るという作業を

$user = User::factory()->create();

factory()で行っており、これはdatabase/factories/UserFactory.phpで行っている。つまり機能テストでデーターを発生させようと思うと一々factoryを書かないといけない。従ってseedさせる時もfactoryからseedさせるのがベストプラクティスという事になる。

そして

$this->actingAs($user);

により疑似ログインを発生させる。これにより以降、このユーザーセッションを持ったまま行動できる。

ユーザーセッションを持ったまま

$this->get(route('dashboard'))->assertOk();

しているので、問題なく表示される事が確約されているわけだ。

以上が基本的なphpunitを使ったLaravel機能テスト

ここで見てきたように、Laravelの機能テストは常にデーターベースをクリアーにして、特定の状況のテストを多角的に実行するというのを目的としている。結果的にこのテストはController - Model - Viewなどを一本化してユーザーのrouteアクセスに対するテストを多角的にテストし、その状態を保証し続ける事を目的としているわけである。テストを沢山書いてもいいが、「この状態が壊れては困る」という事に関してテストを書いていかないといけない。特に初発のテストに関しては経験則が問われるのと、一人の視点だけでテストを書くと意外と見落しがあるので複数人でテストケースを書いていくのが好ましくはあるが、そういう人的リソースも時間リソースも足りない場合はAIを使って確保していく必要がある。

特に「実装は AI にかなり任せる」ような開発では、動作の保証をしてくれるのはテストだけになる。だからこそ、ノーテストは論外で、「当たり前に動いてほしいところ」ほどテストで縛る必要がある。

上記にあるようにAIに書かせたコードをAIにテストケースを書かせてテストするという事もあるだろうし、それはやった方がクオリティーは担保できるはずだがここで問題となるのはAIに丸投げしすぎて人間が誰も把握していないとうい状況に陥りがちであるという事である。コードはある程度ブラックボックス化したとしても少なくともテストに関しては人間の視点でクリアーにしておかないと何が何だかわかなくなりますよ。

現代的な Laravel 開発では、「まずはテストで期待する振る舞いを固め、その枠の中で実装(人間でも AI でも)を回す」というスタイルを目指したい。

次回はよりフレントリーなテストと言えるだろうPestについて解説する。

](https://zenn.dev/catatsumuri/books/355577cde4961a

さて、上記のような本があって、これはChirperというつぶやきサイトをLaravel12 + Inertia.js (w/React)で作るあり元は bootstrap.laravel.com (消失済み) を元にした翻訳+加筆である。これをベースとした拡張記事を書いていくが、まずはテストについて何回かに分けて書いてみる。

Laravelの「テスト」は主に2つに大別される

さて、cloneしてきたとしたらば、その tests/ ディレクトリを眺めてみると

$ ls tests/
Feature  TestCase.php  Unit

このようにFeature/ とUnit/ という2つのディレクトリが見えるはずだ。ここでTestCase.phpというのは既定classであり今現在は空になっている

TestCase.php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    //
}

ここでUnitに置くのがユニットテストであるが本稿では取り扱わない。

まずは既存のテストを眺めてみよう

スターターキットが自動的に置いていったテストが結構ある。これをまず確認してみよう。

$ tree tests/Feature/
tests/Feature/
├── Auth
│   ├── AuthenticationTest.php
│   ├── EmailVerificationTest.php
│   ├── PasswordConfirmationTest.php
│   ├── PasswordResetTest.php
│   ├── RegistrationTest.php
│   ├── TwoFactorChallengeTest.php
│   └── VerificationNotificationTest.php
├── DashboardTest.php
└── Settings
    ├── PasswordUpdateTest.php
    ├── ProfileUpdateTest.php
    └── TwoFactorAuthenticationTest.php

3 directories, 11 files

これらは「スターターキットを展開した直後の状態」が正常かどうかを担保するテストである。
したがって、

  • すべて通るなら「ひとまず環境は正常」
  • どれか 1 つでも落ちるなら「そもそも環境か設定が壊れている」

という判断基準になる。

この時点でテストが落ちているなら、原因を理解して直すまでアプリ開発を進めるべきではない。 ここが Laravel の機能テストを書くうえでの前提になる。

全てのテストを行うには以下のようにする

./vendor/bin/sail test

これを行うと

$ ./vendor/bin/sail test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true                                                    0.28s

   PASS  Tests\Feature\Auth\AuthenticationTest
  ✓ login screen can be rendered                                         0.05s
  ✓ users can authenticate using the login screen                        0.06s
  ✓ users with two factor enabled are redirected to two factor challeng… 0.04s
  ✓ users can not authenticate with invalid password                     0.04s
  ✓ users can logout                                                     0.03s
  ✓ users are rate limited                                               0.03s

   PASS  Tests\Feature\Auth\EmailVerificationTest
  ✓ email verification screen can be rendered                            0.04s
  ✓ email can be verified                                                0.04s
  ✓ email is not verified with invalid hash                              0.03s
  ✓ email is not verified with invalid user id                           0.03s
  ✓ verified user is redirected to dashboard from verification prompt    0.03s
  ✓ already verified user visiting verification link is redirected with… 0.04s

   PASS  Tests\Feature\Auth\PasswordConfirmationTest
  ✓ confirm password screen can be rendered                              0.04s
  ✓ password confirmation requires authentication                        0.02s

   PASS  Tests\Feature\Auth\PasswordResetTest
  ✓ reset password link screen can be rendered                           0.03s
  ✓ reset password link can be requested                                 0.24s
  ✓ reset password screen can be rendered                                0.24s
  ✓ password can be reset with valid token                               0.24s
  ✓ password cannot be reset with invalid token                          0.23s

   PASS  Tests\Feature\Auth\RegistrationTest
  ✓ registration screen can be rendered                                  0.03s
  ✓ new users can register                                               0.03s

   PASS  Tests\Feature\Auth\TwoFactorChallengeTest
  ✓ two factor challenge redirects to login when not authenticated       0.03s
  ✓ two factor challenge can be rendered                                 0.04s

   PASS  Tests\Feature\Auth\VerificationNotificationTest
  ✓ sends verification notification                                      0.03s
  ✓ does not send verification notification if email is verified         0.03s

   PASS  Tests\Feature\DashboardTest
  ✓ guests are redirected to the login page                              0.03s
  ✓ authenticated users can visit the dashboard                          0.03s

   PASS  Tests\Feature\Settings\PasswordUpdateTest
  ✓ password update page is displayed                                    0.03s
  ✓ password can be updated                                              0.04s
  ✓ correct password must be provided to update password                 0.03s

   PASS  Tests\Feature\Settings\ProfileUpdateTest
  ✓ profile page is displayed                                            0.04s
  ✓ profile information can be updated                                   0.04s
  ✓ email verification status is unchanged when the email address is un… 0.04s
  ✓ user can delete their account                                        0.03s
  ✓ correct password must be provided to delete account                  0.03s

   PASS  Tests\Feature\Settings\TwoFactorAuthenticationTest
  ✓ two factor settings page can be rendered                             0.04s
  ✓ two factor settings page requires password confirmation when enable… 0.03s
  ✓ two factor settings page does not requires password confirmation wh… 0.03s
  ✓ two factor settings page returns forbidden response when two factor… 0.03s

  Tests:    40 passed (128 assertions)
  Duration: 2.53s

このように全てのテストが実行される。ここで全てのテストが通過すればokだ。通過しない場合は先に進んではいけない。原因を自分で把握して直すか、それが無理ならAIに問い合わせるなどして何とかして直す事。

1つだけテストを取り上げてテストの条件がどうなっているか見てみる(ここではDashboardテストを使う)

この記事で全てのテストの全ての項目を解説するのは余りにも辛いの、1つだけ取り上げて詳細に解説する。ここではtests/Feature/DashboardTest.phpを使う。

この記事では、LLM にコメントを付けてもらったコードをそのまま引用して解説する。

tests/Feature/DashboardTest.php
<?php

namespace Tests\Feature;

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

class DashboardTest extends TestCase
{
    // ------------------------------------------------------------
    // RefreshDatabase:
    //   各テストごとにトランザクション or 再マイグレーションを行い、
    //   DB 状態を完全リセットする。
    //   Feature テストでユーザ作成が必要なため必須。
    // ------------------------------------------------------------
    use RefreshDatabase;

    /**
     * ゲスト(未ログインユーザー)がダッシュボードにアクセスした場合、
     * ログインページへリダイレクトされることを確認する。
     *
     * ポイント:
     * - 認可(ミドルウェア: auth)の挙動を検証する典型的 Feature テスト。
     * - `assertRedirect()` で遷移先ルートを明示することで意図がブレない。
     */
    public function test_guests_are_redirected_to_the_login_page()
    {
        // GET /dashboard にアクセス(未ログイン)
        $this->get(route('dashboard'))
            // 認証ミドルウェアにより login へ飛ばされるはず
            ->assertRedirect(route('login'));
    }

    /**
     * 認証済みユーザーがダッシュボードを正常に閲覧できることを確認する。
     *
     * ポイント:
     * - UserFactory で DB にユーザを1件作成
     * - actingAs() でログイン状態をシミュレート
     * - ダッシュボードが 200 OK を返すことが期待値
     */
    public function test_authenticated_users_can_visit_the_dashboard()
    {
        // テスト用のユーザを作成
        $user = User::factory()->create();

        // テスト実行中だけそのユーザでログイン状態にする
        $this->actingAs($user);

        // GET /dashboard にアクセス(ログイン済み)
        $this->get(route('dashboard'))
            ->assertOk();  // HTTP 200 が返ること
    }
}

ここで重要な概念として

    // ------------------------------------------------------------
    // RefreshDatabase:
    //   各テストごとにトランザクション or 再マイグレーションを行い、
    //   DB 状態を完全リセットする。
    //   Feature テストでユーザ作成が必要なため必須。
    // ------------------------------------------------------------
    use RefreshDatabase;

これ。

従ってtestingという完全に消していいデーターベースが必要となるわけだ。

test_guests_are_redirected_to_the_login_pageを見る

    /**
     * ゲスト(未ログインユーザー)がダッシュボードにアクセスした場合、
     * ログインページへリダイレクトされることを確認する。
     *
     * ポイント:
     * - 認可(ミドルウェア: auth)の挙動を検証する典型的 Feature テスト。
     * - `assertRedirect()` で遷移先ルートを明示することで意図がブレない。
     */
    public function test_guests_are_redirected_to_the_login_page()
    {
        // GET /dashboard にアクセス(未ログイン)
        $this->get(route('dashboard'))
            // 認証ミドルウェアにより login へ飛ばされるはず
            ->assertRedirect(route('login'));
    }

コメントの通りである。基本的にログインしていないユーザーが /dashboard とかを直で打ってきても見えない事を保証する。テスト実行時には認証されない状態であるので、route('dashboard')を直GETすれば認証なしで /dashboard にアクセスした事になるというわけだ。

この時は認証せずに /dashboardにアクセスしようとしているため、ログイン画面に引き戻されるのが期待された動作になる。それを

->assertRedirect(route('login'));

で表現している。

続いて、test_authenticated_users_can_visit_the_dashboardを見る

    /**
     * 認証済みユーザーがダッシュボードを正常に閲覧できることを確認する。
     *
     * ポイント:
     * - UserFactory で DB にユーザを1件作成
     * - actingAs() でログイン状態をシミュレート
     * - ダッシュボードが 200 OK を返すことが期待値
     */
    public function test_authenticated_users_can_visit_the_dashboard()
    {
        // テスト用のユーザを作成
        $user = User::factory()->create();

        // テスト実行中だけそのユーザでログイン状態にする
        $this->actingAs($user);

        // GET /dashboard にアクセス(ログイン済み)
        $this->get(route('dashboard'))
            ->assertOk();  // HTTP 200 が返ること
    }

ここではユーザーを1つ作ってそのユーザーに対して「認証済み」の状態を発生させた状態で /dashboardとかをGETするとassertOk()となる事(つまりリダイレクトとかされない)ことを保証している。ここでユーザーを作るという作業を

$user = User::factory()->create();

factory()で行っており、これはdatabase/factories/UserFactory.phpで行っている。つまり機能テストでデーターを発生させようと思うと一々factoryを書かないといけない。従ってseedさせる時もfactoryからseedさせるのがベストプラクティスという事になる。

そして

$this->actingAs($user);

により疑似ログインを発生させる。これにより以降、このユーザーセッションを持ったまま行動できる。

ユーザーセッションを持ったまま

$this->get(route('dashboard'))->assertOk();

しているので、問題なく表示される事が確約されているわけだ。

以上が基本的なphpunitを使ったLaravel機能テスト

ここで見てきたように、Laravelの機能テストは常にデーターベースをクリアーにして、特定の状況のテストを多角的に実行するというのを目的としている。結果的にこのテストはController - Model - Viewなどを一本化してユーザーのrouteアクセスに対するテストを多角的にテストし、その状態を保証し続ける事を目的としているわけである。テストを沢山書いてもいいが、「この状態が壊れては困る」という事に関してテストを書いていかないといけない。特に初発のテストに関しては経験則が問われるのと、一人の視点だけでテストを書くと意外と見落しがあるので複数人でテストケースを書いていくのが好ましくはあるが、そういう人的リソースも時間リソースも足りない場合はAIを使って確保していく必要がある。

特に「実装は AI にかなり任せる」ような開発では、動作の保証をしてくれるのはテストだけになる。だからこそ、ノーテストは論外で、「当たり前に動いてほしいところ」ほどテストで縛る必要がある。

上記にあるようにAIに書かせたコードをAIにテストケースを書かせてテストするという事もあるだろうし、それはやった方がクオリティーは担保できるはずだがここで問題となるのはAIに丸投げしすぎて人間が誰も把握していないとうい状況に陥りがちになるという事である。コードはある程度ブラックボックス化するのが否めない状況であったとしても、少なくともテストに関しては人間の視点でクリアーにしておかないと何が何だかわかなくなりますよ。というわけで現代的な Laravel 開発では、「まずはテストで期待する振る舞いを固め、その枠の中で実装(人間でも AI でも)を回す」というスタイルを目指したい。

次回はよりフレントリーなテストと言えるだろうPestについて解説する。

Discussion