【Laravel】PHPUnitでテストコードを書くときのTipsやサンプルコード

Laravel Advent Calendar 2021 17日目の記事です。
僕自身が書いた経験のある範囲でPHPUnitでテストコードを書くときのTipsやサンプルコードをまとめます。

https://qiita.com/advent-calendar/2021/laravel

## はじめに

いきなりタイトルには直接関係ない小ネタを挟みます。

### テスト関連のコマンド

テストファイル作成

```sh
# tests/Featureに作成される
php artisan meke:test SampleTest

# tests/Unitに作成される
php artisan make:test SampleTest --unit

# ディレクトリを指定してファイル作成
# tests/Feature/User配下
php artisan make:test User/SampleTest
# tests/Unit/User配下
php artisan make:test User/SampleTest --unit
```

テスト実行

```sh
# 事前実行
php artisan config:clear

# 全テストファイル実行（以下どちらでも可能ですが出力結果のUIが異なります。僕は前者を使っています）
php artisan test
./vendor/bin/phpunit

# 特定のテストファイル実行
php artisan test tests/Feature/PreUserControllerTest.php
```

### テスト用DBの準備

ローカル環境で実行するテストで使うDBの準備は`phpunit.xml`のデフォルトでコメンアウトされている2行のコメントを外すだけで、インメモリのSQLiteを使うことができます。

```xml:phpunit.xml
<!-- <server name="DB_CONNECTION" value="sqlite"/> -->
<!-- <server name="DB_DATABASE" value=":memory:"/> -->
```

これはローカル環境をDockerで構築している場合でも同様で、わざわざテスト用のDBコンテナを用意する必要なく、DBが絡むテストを実行することができますので個人的にオススメです。

## コードサンプル

ここから僕自身がこれまで実装したことのある範囲で「こういうケースはこんな感じでコードが書けば実装できるよ」というのをいくつかまとめます。

本記事の内容のベタープラクティス、ベストプラクティスはあると思いますが、参考にしていただけたら幸いです。

### ハッシュ化されたパスワードの比較

片方が平文（POSTデータのパラメータとか）、片方がハッシュ化されている（DBに登録されているデータとか）の場合の比較には`Hash::check()`を使う。

```php
/**
* @test 
*/
public function fecth_リクエストパラメータからユーザー情報の取得に成功する(): void
{
    $email = 'test@example.com';
    $password = 'password';

    $service = new FetchService();
    // ユーザー情報取得
    $response = $service->fetch($email, $password);

    // メールアドレスの比較検証
    $this->assertSame($email, $response->email);
    // パスワードの比較検証
    $this->assertTrue(Hash::check($password, $response->password));
}
```

https://laravel.com/docs/8.x/hashing#verifying-that-a-password-matches-a-hash

ちなみに同値チェックには`assertSame`と`assertEquals`があるか前者は型も検証要素になる（型も同じかどうかをチェックしてくれる）ので基本的に`assertSame`を使った方が良いと思います。

### Mailaleクラス（メール送信）のテスト

個人的にこの記事を参考にするのが良いかと思います。

https://developer.dip-net.co.jp/entry/2021/03/05#:~:text=%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84%E3%80%82-,%E3%83%86%E3%82%B9%E3%83%88%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E6%9B%B8%E3%81%84%E3%81%A6%E3%81%BF%E3%82%8B,-%E3%83%A1%E3%83%BC%E3%83%AB%E9%80%81%E4%BF%A1%E3%81%BE%E3%81%A7

一応、下にコードを記載。

```php:
/**
* @test
*/
public function sendMail_メールが1通送信されている()
{
    $email = 'test@example.com';

    // 実際にはメールを送らないように設定
    Mail::fake();
    // メールが送られていないことを確認
    Mail::assertNothingSent();

    $service = new SendMailService();
    $service->sendMail($email);

    // メッセージが指定したユーザーに届いたことをアサート
    // (SampleMailがMailableクラスです）
    Mail::assertSent(SampleMail::class, function ($mail) use ($email) {
        return $mail->hasTo($email);
    });

    // メールが1回送信されたことをアサート
    Mail::assertSent(SampleMail::class, 1);
    }
```

### 例外（Exception）のテスト

例外のテストは基本的に以下の2つのメソッドを使う。

```php
// 例外クラスの検証
$this->expectException();
// メッセージの検証
$this->expectExceptionMessage();
```

基本形は以下の形で書く。

```php
/**
* @test
*/
public function test_例外の検証()
{
    $this->expectException(Exception::class);
    $this->expectExceptionMessage('エラーです。');

    $service = new SampleService();
    $service->testMethod();
}
```

例外のテストの場合は通常のテストみたいに処理の最後に`$this->assertStatus()`とかを書かず、`$this->expectException()`や`$this->expectExceptionMessage()`を1番上に書く。

例外のテストを書く時の注意点は以下のような感じで`try-catch`の中で例外を投げているメソッドの場合は上記のテストコードでは上手くいかない。
（テストを実行すると`Failed asserting that exception of type "Exception" is thrown.`というエラーになるはず）

```php
try {
    // 略
    throw new Exception('エラーです');
} catch (Exception $e) {
    throw $e;
}
```

このような場合の例外のテストでは以下のように1番上に`$this->withoutExceptionHandling();`を追加すると正常にテストできる。

```php
/**
* @test
*/
public function test_例外の検証()
{
    // これが必要
    $this->withoutExceptionHandling();

    $this->expectException(Exception::class);
    $this->expectExceptionMessage('エラーです。');

    $service = new SampleService();
    $service->testMethod();
}
```

ソースは以下。

https://laracasts.com/discuss/channels/testing/expectexception-doesnt-work-when-it-is-catched

※ちなみに「PHPUnit 例外 テスト」とかでググると以下のアノテーションで例外の検証する方法がヒットしますが、PHPUnit9系から廃止されています。

```php
/**
* @test
* @expectedException
* @expectedExceptionMessage
*/
```

https://blog.okashoi.net/entry/2019/09/04/155400

PHPUnitのドキュメントの中で「例外のテスト」には上記アノテーションを使った方法は紹介されていません。（アノテーション自体は掲載されている...）

https://phpunit.readthedocs.io/ja/latest/writing-tests-for-phpunit.html#writing-tests-for-phpunit-exceptions

### UnitテストでFactoryのunique()、safeEmail()を使うとき

Unitテストファイルをコマンド（`php artisan make:test SampleTest --unit`）で生成するときは自動的にテストクラスの継承元のスーパークラスが`PHPUnit\Framework\TestCase`となる。

このままの状態でモデルファクトリでテスト用データを生成する時に`unique()`や`safeEmail()`を使うと以下のエラーになります。

```
Unknown formatter "unique"
Unknown formatter "safeEmail"
```

対策としてはUnitテストでもファクトリの`unique()`や`safeEmail()`を使うときは継承元のスーパークラスをFeatureテストと同じく`Tests\TestCase`に変更する。

（use文を`use PHPUnit\Framework\TestCase;` → `use Tests\TestCase;`に変更する）

https://stackoverflow.com/questions/65035859/laravel-8-tests-phpunit-error-unknown-formatter-unique

### シーダーファイル実行

以下のコードで既存のシーダーファイル（`/database/seeders`配下）を実行してテスト用DBにテストデータを登録することができる。

```php
$this->artisan('db:seed', ['--class' => 'SampleTableSeeder']);
```

### actingAsを使った時に出る警告への対応

ログイン状態を実現するための`actingAs()`ですが、以下のような書き方ではVSCode上で警告が出ます。

- テストクラスのプロパティとして`$user`を定義
- `setUp()`の中で`$this->user`を使って`actingAs()`

```php
private $user;

public function setUp(): void
{
    parent::setUp();
    $this->user = User::factory()->create();
    $this->actingAs($this->user, 'web');
}
```

こんな警告が出ます。

`Expected type 'Illuminate\Contracts\Auth\Authenticatable'. Found 'Illuminate\Database\Eloquent\Collection|Illuminate\Database\Eloquent\Model'）`

※LaraStanで静的解析しても出ます（解析レベル5で試行）

https://github.com/nunomaduro/larastan

上記コードで警告を解消する方法は2通りある。

### テストクラスのプロパティで指定（`$user）`→`setUp()`内でfactoryでユーザー生成→テストメソッドで`actingAs()`

```php
private $user;

public function setUp(): void
{
    parent::setUp();
    $this->user = User::factory()->create();
}

/**
* @test
*/
public function test_サンプルのテストメソッドです()
{
    $this->actingAs($this->user, 'web');
    // 略
}
```

### テストメソッド内でfactoryでユーザー生成（`@var`指定必要）→そのユーザーで`actingAs()`

ユーザーを生成した時に`/** @var \Illuminate\Contracts\Auth\Authenticatable $user */`を指定して警告にもあるように$userを`'Illuminate\Contracts\Auth\Authenticatable'`のインスタンスとして定義してあげる。

```php
/**
* @test
*/
public function test_サンプルのテストメソッドです()
{
    /** @var \Illuminate\Contracts\Auth\Authenticatable $user */
    $user = User::factory()->create();
    $this->actingAs($user, 'web');
    // 略
}
```

上記の繰り返しになるが、結論としては以下の2つの方法のどちらかで書くのが良いと思われる。

- テストクラスのプロパティで指定（`$user）`→`setUp()`内でfactoryでユーザー生成→テストメソッドで`actingAs()`
- テストメソッド内でfactoryでユーザー生成（@var指定必要）→そのユーザーで`actingAs()`

### DBから取得したデータのテスト

「どこまで厳密に検証するか？」によって書き方が異なる。厳しい順にご紹介。

#### データの中身までチェックする

```php
/**
 * @test
 */
public function fetch_ユーザーデータを全件取得する(): void
{
    // 略（$responseにコレクションが格納されているとする）

    // 1つ目のインスタンスを配列に変換する
    $responseArray = $response[0]->toArray();
    $this->assertSame([
        'id',
        'name',
        'email',
        'created_at',
        'updated_at'
    ], array_keys($responseArray));
}
```

`->first()`で1件のデータ取得の場合は、レスポンスがモデルインスタンスなので、以下の書き方で配列に変換する

```php
$responseArray = $response->toArray();
```

#### 特定のクラスのインスタンスかどうかだけチェックする

`instanceof`を使って特定のモデルインスタンスであるかどうかを検証する。

```php
// 1件のデータを取得する場合
$this->assertTrue($response instanceof SampleModel)

// コレクションの場合
$this->assertTrue($response[0] instanceof SampleModel)
```

検証内容は同じだが以下のように`assertInstanceOf`というアサーションを使った方がわかりやすいかも。

```php
// 1件のデータを取得する場合
$this->assertInstanceOf(SampleModel::class, $response)

// コレクションの場合
$this->assertInstanceOf(SampleModel::class, $response[0])
```
https://phpunit.readthedocs.io/ja/latest/assertions.html#assertinstanceof

#### オブジェクトかどうかだけチェックする

１番緩い。これならわざわざ検証する必要もないんじゃないか？と思うレベル。

```php
// 1件のデータを取得する場合
$this->assertIsObject($response);

// コレクションの場合
$this->assertIsObject($response[0]);
```

### JSONでCollectionを返却するメソッドのテスト

先ほどの「DBから取得したデータのテスト」と被るところもあるけど、CollectionをJSONに要素として返却するメソッドのテスト考える。
「DBから取得したデータのテスト」はモデルやサービスのテストのイメージで、こっちはControllerのイメージ。

#### レスポンスの中身（key・valueの組み合わせまで）を検証する

月火水木金土日のデータを全件取得してJSONを返却するメソッドのテストとする。

```php
/**
 * @test
 */
public function fetch_曜日データを全件取得する(): void
{
    $response = $this->getJson('/api/week');

    $response->assertStatus(200)
        ->assertJson([
            'data' => [
                [
                    'id' => 1,
                    'name' => 月曜,
                ],
                [
                    'id' => 2,
                    'name' => 火曜,
                ],
                [
                    'id' => 3,
                    'name' => 水曜,
                ],
                [
                    'id' => 4,
                    'name' => 木曜,
                ],
                [
                    'id' => 5,
                    'name' => 金曜,
                ],
                [
                    'id' => 6,
                    'name' => 土曜,
                ],
                [
                    'id' => 7,
                    'name' => 日曜,
                ],
            ]
        ]);
}
```

上記サンプルメソッドのように返却する要素の個数が確定しているときは`assertJson`でJSONの中身まで検証するのが良いと思われる。
（一応`assertStatus(200)`でレスポンスのステータスも検証しています）

#### レスポンスの構造を検証する

「投稿データの全件取得」等、データの個数が多いor確定できない場合は、`assertJson`を使って中身を検証するとコードがめちゃくちゃ多くなるので、こういう時はJSONの構造を検証する。

```php
/**
 * @test
 */
public function fetch_投稿データを全件取得する(): void
{
    $response = $this->getJson('/api/posts');

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data' => [
                '*' => [
                    'id',
                    'user_id',
                    'body',
                ]
            ]
        ]);
}
```

`assertJsonStructure`を使うことでJSONの構造を検証することができる。
`'*'`は任意の文字を許容するので配列の添字が入ることで多量のデータの構造を検証することができる。

### JsonResponseを配列に変換してテストする

先ほどはJSON型のレスポンス（Laravelでは`JsonResponse`型）をJSONとしてPHPUnitの`assert〜`メソッドで検証したけど、`JsonResponse`を配列に変換してテストする方法もある。

```php
/**
 * @test
 */
public function fetch_ユーザーデータを全件取得する(): void
{
    // 略（$responseにJSONが格納されているとする）

    $responseArray = json_decode($response->content(), true);
    $questionnaire = $responseArray[0];
    $this->assertSame([
        'id',
        'name',
        'email',
        'created_at',
        'updated_at'
    ], array_keys($questionnaire));
}
```

PHPの組み込みメソッド`json_decode`を使ってJSON→配列の変換を行う。
`json_decode`の第二引数に`true`を渡すと連想配列に、`false`を渡すとオブジェクトに変換する。

https://www.php.net/manual/ja/function.json-decode.php

## 最後に

僕はこれまで業務で書いてきたテストコードの中で「こんな風に書いたらテストできるよ！」というのをまとめました。
個人的にテストコードを書くのは楽しいですが、業務でテストコードを書いた歴は3ヶ月程度なのでもっと上手く書く方法があるんだろうなと思っています。。