Laravelの巨大関数にはPHPUnitとFactoryを使うのだ!!レガシーコードに終止符の巻

Laravelの巨大関数にはPHPUnitとFactoryを使うのだ!!レガシーコードに終止符の巻 のサムネイル画像
投稿日: ・ タグ: PoemLaravelPHPPHPUnitLegacy Code

現在絶賛レガシーコード対応中のこなつと申します。
みなさんはちゃんとテストコード書いてますか?
巷ではテストコードがないプロジェクトがレガシーコードだと揶揄されたりします。
現在僕が対応中のプロジェクトにはLaravelを使っているにも関わらずなんと「tests」が空っぽです。

そして肥大化したControllersクラスのメソッド(Fat Controller)
責任分離するために全ロジック集中させて肥大化したServiceクラスのメソッド(Fat Service)
それを改善するために肥大化したModelクラスのメソッド(Fat Model)

キャスト「赤井さ~~ん。どうしてそんなにおっきくなっちゃたんですか?」
赤井さん「なんでやろな~~」

みたいな状態のコードをまずはリファクタリングするためにPHPUnitとFactoryを使って現在進行形でテストコードを書きまくっています。

LaravelにはPHPUnitとFactoryという便利な機能があるのに一切使わないがために特級呪霊並みの巨大な関数が大量発生してるプロジェクトってありませんか?

そんな巨大な関数に対してまずは何をすべきか迷っている方や「テストコード?何それ、美味いの?必要なん?」という方向けにPHPUnitとFactoryを使ったテストコードの書き方と必要性を解説します。

そもそもコードが触れる状態でない

では例としてこんがり焼けたレガシーな香りがしそうなコードを簡単に書いてみます。(ChatGPT生成!!)

class HomeController extends Controller
{
    /**
     * ホームページを表示するコード
     */
    public function index(Request $request)
    {
        // user_idを取得
        $userId = $request->input('user_id');
        $greeting = 'ようこそ!';

        // ユーザー取得と挨拶メッセージ設定
        if ($userId) {
            $user = User::find($userId);

            if ($user) {
                if ($user->type === 'admin') {
                    $greeting = '管理者としてようこそ!';
                } elseif ($user->type === 'member') {
                    $greeting = 'メンバーの皆さん、こんにちは!';
                } elseif ($user->type === 'guest') {
                    $greeting = 'ゲストユーザーさん、いらっしゃいませ!';
                } else {
                    $greeting = 'ようこそ!';
                }
            } else {
                $greeting = 'ユーザーが見つかりませんでした。';
            }
        }

        return view('home', [
            'greeting' => $greeting,
        ]);
    }
}

これはあくまで例ですが、テストコードが一切書かれていないプロジェクトなら同じような処理がこのあと数百行以上つづきます。
はい、吐き気がしますね。
テストコードをはじめから書いていれば必然的にテストしやすいコードになるはずなんです。

そもそもこんな状態ではコードは触れたものじゃありません。

もしテストコードを書かないと、、、、

自分「このコードちょっと見にくいな。よし!まずはelse文を使わずにswitch文を使うようにリファクタリングや!!」

class HomeController extends Controller
{
    /**
     * ホームページを表示するコード
     */
    public function index(Request $request)
    {
        // 処理がつづく

        switch ($user->type) {
            // if ($user->type === 'admin') の分岐処理が抜けている
            case 'member':
                $greeting = 'メンバーの皆さん、こんにちは!';
                break;
            case 'guest':
                $greeting = 'ゲストユーザーさん、いらっしゃいませ!';
                break;
            default:
                $greeting = 'ようこそ!';
        }

        // 処理がつづく
    }
}

自分「赤井さん!コード見にくかったんでリファクタリングしときました!」
赤井さん「おうサンキューな!あれ?俺管理者やのに管理者のメッセージが表示されへんやんけ!どないなっとんねん!!」
自分「ε=ε=ε=ヾ(*´Д`)ノ逃げろォォォォ」

ということになりかねません。

では何をすべきか、、、

まずはコードを触れる状態にしないといけません。

Factoryを作る

初めにUserモデルのFactoryを作ります。
ではLaravelおなじみの「artisan」コマンドでファクトリーのファイルを作ります。

$ php artisan make:factory User

「database/factories/UserFactory.php」が作成されます。
できたてほやほやUserFactoryつまりユーザー工場の出来上がり。

ほぼLaravel構築時のデフォルトの状態でtypeを追加しただけですが、中身はこんな感じです。

class UserFactory extends Factory
{
    /**
     * The current password being used by the factory.
     */
    protected static ?string $password;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'email_verified_at' => now(),
            'type' => 'guest', ← 追加
            'password' => static::$password ??= Hash::make('password'),
            'remember_token' => Str::random(10),
        ];
    }

    /**
     * Indicate that the model's email address should be unverified.
     */
    public function unverified(): static
    {
        return $this->state(fn (array $attributes) => [
            'email_verified_at' => null,
        ]);
    }
}

テストコード作成

次にテストコードを書いていきます。
では「tests/Feature/HomeControllerTest.php」を作成して機能テストを書いていきます。
ざっと中身はこんな感じです。

<?php

namespace Tests\Feature\Controllers;

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

class HomeControllerTest extends TestCase
{
    use RefreshDatabase; // 1つの関数で実行が完了したらDBの内容をロールバックする

    public function test_index_without_user_id()
    {
        $response = $this->get('/');

        $response->assertStatus(200);
        $response->assertViewIs('home');
        $response->assertViewHas('greeting', 'ようこそ!');
    }

    public function test_index_with_admin_user()
    {
        $admin = User::factory()->create([
            'type' => 'admin',
        ]);

        $response = $this->get('/?user_id=' . $admin->id);

        $response->assertStatus(200);
        $response->assertViewHas('greeting', '管理者としてようこそ!');
    }

    public function test_index_with_member_user()
    {
        $member = User::factory()->create([
            'type' => 'member',
        ]);

        $response = $this->get('/?user_id=' . $member->id);

        $response->assertStatus(200);
        $response->assertViewHas('greeting', 'メンバーの皆さん、こんにちは!');
    }

    public function test_index_with_guest_user()
    {
        $guest = User::factory()->create([
            'type' => 'guest',
        ]);

        $response = $this->get('/?user_id=' . $guest->id);

        $response->assertStatus(200);
        $response->assertViewHas('greeting', 'ゲストユーザーさん、いらっしゃいませ!');
    }

    public function test_index_with_unknown_type_user()
    {
        $other = User::factory()->create([
            'type' => 'unknown',
        ]);

        $response = $this->get('/?user_id=' . $other->id);

        $response->assertStatus(200);
        $response->assertViewHas('greeting', 'ようこそ!');
    }

    public function test_index_with_invalid_user_id()
    {
        $response = $this->get('/?user_id=99999');

        $response->assertStatus(200);
        $response->assertViewHas('greeting', 'ユーザーが見つかりませんでした。');
    }
}

一つ一つ解説していきます。

RefreshDatabaseトレイトについて

use RefreshDatabase; // 1つの関数で実行が完了したらDBの内容をロールバックする

RefreshDatabaseトレイトは開発環境によって必要の有無が変わってきます。
ローカル環境や開発環境で特にPHPUnitテスト用のDBと開発用のDBを分けない場合はこれを設定します。
つまりDBを一つしか使わない場合は、ファクトリーでテスト用に生成したユーザーデータが開発環境に影響を与えないようにします。

UserFactoryについて

Factoryは本当に便利ですね。
必要な情報のみ設定すれば後は簡単にユーザーデータを工場のように生産できるんです。

$admin = User::factory()->create([
    'type' => 'admin',
]);

factoryの引数に数を渡せばtypeがadminのユーザーデータを10件一気に作ることもできます。
これをEloquentやQuery Builderだけでやろうとすると大変ですからね、、、まさかそんなことしてませんよね?

$admin = User::factory(10)->create([
    'type' => 'admin',
]);

テストケースについて

今回の例では下記6通りのテストケースが洗い出せました。

  • ユーザーなし
  • 管理者ユーザー
  • メンバーユーザー
  • ゲストユーザー
  • 不明なタイプのユーザー
  • 存在しないユーザー

コメントを入れるとこんな感じです。

    // ユーザーなし
    public function test_index_without_user_id()
    {
        // 処理がつづく
    }

    // 管理者ユーザー
    public function test_index_with_admin_user()
    {
        // 処理がつづく
    }

    // メンバーユーザー
    public function test_index_with_member_user()
    {
        // 処理がつづく
    }

    // ゲストユーザー
    public function test_index_with_guest_user()
    {
        // 処理がつづく
    }

    // 不明なタイプのユーザー
    public function test_index_with_unknown_type_user()
    {
        // 処理がつづく
    }

    // 存在しないユーザー
    public function test_index_with_invalid_user_id()
    {
        // 処理がつづく
    }

ですが実際のプロジェクトでは1つの関数でもっと大量のテストケースができると思いますので、そこは根気で洗い出してください。
カバレッジツールを使う手もあるかもですが、自分は今のところ使ったことないです。
どうせなら今度使ってみようと思います。

PHPUnit実行

「時は満ちた、、、、、」ということでテスト実行してみます。
「php artisan test」を実行します。

$ php artisan test

   PASS  Tests\Feature\Controllers\HomeControllerTest
  ✓ index without user id                                                                                                                                                                                                             0.08s  
  ✓ index with admin user                                                                                                                                                                                                             0.05s  
  ✓ index with member user                                                                                                                                                                                                            0.01s  
  ✓ index with guest user
  ✓ index with unknown type user
  ✓ index with invalid user id

  Tests:    6 passed (13 assertions)
  Duration: 0.17s

気持ちい~~~~~
全テスト合格です!!
これで晴れて来月からあなたも東大生です。

コードが触れる状態になったら

テストが全て通れば後は煮るなり焼くなりコードを自由に扱えるようになります。
先ほどのデグレが起こるコードのように間違ったリファクタリングをしてテスト実行してみます。
まずはリファクタリング。

class HomeController extends Controller
{
    /**
     * ホームページを表示するコード
     */
    public function index(Request $request)
    {
        // 処理がつづく

        switch ($user->type) {
            // if ($user->type === 'admin') の分岐処理が抜けている
            case 'member':
                $greeting = 'メンバーの皆さん、こんにちは!';
                break;
            case 'guest':
                $greeting = 'ゲストユーザーさん、いらっしゃいませ!';
                break;
            default:
                $greeting = 'ようこそ!';
        }

        // 処理がつづく
    }
}

さあ「php artisan test」でテスト実行します。

$ php artisan test

   FAIL  Tests\Feature\Controllers\HomeControllerTest
  ✓ index without user id                                                                                                                                                                                                             0.24s  
  ⨯ index with admin user                                                                                                                                                                                                             0.07s  
  ✓ index with member user                                                                                                                                                                                                            0.01s  
  ✓ index with guest user                                                                                                                                                                                                             0.01s  
  ✓ index with unknown type user                                                                                                                                                                                                      0.01s  
  ✓ index with invalid user id
  ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────  
   FAILED  Tests\Feature\Controllers\HomeControllerTest > index with admin user                                                                                                                                                              
  Failed asserting that [greeting] matches the expected value.
Failed asserting that two strings are equal.
  -'管理者としてようこそ!'
  +'ようこそ!'
  

  at tests/Feature/HomeControllerTest.php:31
     27▕ 
     28▕         $response = $this->get('/?user_id=' . $admin->id);
     29▕ 
     30▕         $response->assertStatus(200);
  ➜  31▕         $response->assertViewHas('greeting', '管理者としてようこそ!');
     32▕     }
     33▕ 
     34▕     public function test_index_with_member_user()
     35▕     {


  Tests:    1 failed, 5 passed (13 assertions)
  Duration: 0.43s

テストに失敗しているのがわかりますね。
これで提出前に気づけるので赤井さんに怒られずに済みそうです。 (´∀`)ヨカッタ

では正しいリファクタリングをします。

class HomeController extends Controller
{
    /**
     * ホームページを表示するコード
     */
    public function index(Request $request)
    {
        // 処理がつづく

        switch ($user->type) {
            case 'admin':
                $greeting = '管理者としてようこそ!';
                break;
            case 'member':
                $greeting = 'メンバーの皆さん、こんにちは!';
                break;
            case 'guest':
                $greeting = 'ゲストユーザーさん、いらっしゃいませ!';
                break;
            default:
                $greeting = 'ようこそ!';
        }

        // 処理がつづく
    }
}

テスト実行!!!

$ php artisan test

   PASS  Tests\Feature\Controllers\HomeControllerTest
  ✓ index without user id                                                                                                                                                                                                             0.18s  
  ✓ index with admin user                                                                                                                                                                                                             0.04s  
  ✓ index with member user
  ✓ index with guest user
  ✓ index with unknown type user
  ✓ index with invalid user id                                                                                                                                                                                                        0.01s  

  Tests:    6 passed (13 assertions)
  Duration: 0.32s

リファクタリングしたコードでテストに成功しました。
この状態になってやっと開発ができる状態になります。

まとめ

「テストコードは書こう!!以上!!!」
結論そういうことですが、実際のプロジェクトでは上層部の判断とかでなかなかテストコードを書く文化に至らないケースもあると思います。
ですがテストコードが与えてくれる恩恵が大きいことに間違いはないので、すこしずつでも世界がテストコードで救われる未来を望んでおります。

それではまた。