Laravelのユニットテストで詰まった話

## はじめに
~~タイトルにある通り未解決ですが、~~ Laravelでユニットテストをコーディングしていた時に起こった事を書きます。
テストクラスにはメソッドが２つあり、１つずつテストを実行した時は問題なくそれぞれPassします。
しかし、通しで２つ同時に実行するとこけちゃいます。

### 環境
```terminal
$php --version
PHP 8.0.16 (cli) (built: Feb 18 2022 20:51:37) ( NTS )
Copyright (c) The PHP Group
Zend Engine v4.0.16, Copyright (c) Zend Technologies

$php artisan --version
Laravel Framework 8.36.2

$phpunit --version
PHPUnit 9.5.10 by Sebastian Bergmann and contributors.

$mysql --version
mysql  Ver 14.14 Distrib 5.6.38, for osx10.9 (x86_64) using  EditLine wrapper
```

## テスト準備
テスト用のDBでテストするための準備を簡単に書きます。

```.env.testing
APP_ENV=testing
DB_CONNECTION=testing
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=test
DB_USERNAME=root
DB_PASSWORD=root
```

```phpunut.xml
<php>
    <server name="APP_ENV" value="testing"/>
    <server name="BCRYPT_ROUNDS" value="4"/>
    <server name="CACHE_DRIVER" value="array"/>
    <!-- <server name="DB_CONNECTION" value="sqlite"/> -->
    <!-- <server name="DB_DATABASE" value=":memory:"/> -->
    <server name="MAIL_MAILER" value="array"/>
    <server name="QUEUE_CONNECTION" value="sync"/>
    <server name="SESSION_DRIVER" value="array"/>
    <server name="TELESCOPE_ENABLED" value="false"/>
</php>
```

```migration_create_clients.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTblClient extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        // テストに関係ないテーブル定義は簡略しています。
        Schema::create('clients', function (Blueprint $table) {
            $table->increments('id');
            $table->string('client_id');
            $table->string('type');
            $table->string('name');
            $table->integer('test_flag');
            $table->timestamp('reg_date');
            $table->timestamp('upd_date');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('clients');
    }
}
```


```ClientSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Client;
use Illuminate\Database\Seeder;

class ClientSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        Client::insert([
            [
//                'id' => 1,
                'client_id' => 'hoge',
                'type' => 'super',
                'name' => 'ほげ',
                'test_flag' => 0,
            ],
            [
//                'id' => 2,
                'client_id' => 'hogehoge',
                'type' => 'super',
                'name' => 'ほげほげ',
                'test_flag' => 0,
            ],
            [
//                'id' => 3,
                'client_id' => 'fuga',
                'type' => 'super',
                'name' => 'ふが',
                'test_flag' => 0,
            ],
            [
//                'id' => 4,
                'client_id' => 'fugafuga',
                'type' => 'super',
                'name' => 'ふがふが',
                'test_flag' => 0,
            ],
            [
//                'id' => 5,
                'client_id' => 'hogefuga',
                'type' => 'super',
                'name' => 'ほげふが',
                'test_flag' => 1,
            ],
        ]);
    }
}
```

```DatabaseSeeder.php
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        $this->call([
            ClientSeeder::class,
        ]);
    }
}
```

```ClientTest.php
<?php

namespace Tests\Unit;

use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ClientTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();
        $this->artisan('db:seed');
        $this->client = $this->app->make('App\略\Client');
    }

    public function test_fetchNonTestClientType()
    {
        $type = 'super';
        $search = 'h';
        $res = $this->client->fetchNonTestClientType($type, $search);
        $this->assertEquals([
            [
                'id' => 1,
                'client_id' => 'hoge',
                'name' => 'ほげ',
            ],
            [
                'id' => 2,
                'client_id' => 'hogehoge',
                'name' => 'ほげほげ',
            ],
        ], $res);

        $search = 'ふが';
        $res = $this->client->fetchNonTestClientType($type, $search);
        $this->assertEquals([
            [
                'id' => 3,
                'client_id' => 'fuga',
                'name' => 'ふが',
            ],
            [
                'id' => 4,
                'client_id' => 'fugafuga',
                'name' => 'ふがふが',
            ],
        ], $res);

        $search = '';
        $res = $this->client->fetchNonTestClientType($type, $search);
        $this->assertEquals([
            [
                'id' => 1,
                'client_id' => 'hoge',
                'name' => 'ほげ',
            ],
            [
                'id' => 2,
                'client_id' => 'hogehoge',
                'name' => 'ほげほげ',
            ],
            [
                'id' => 3,
                'client_id' => 'fuga',
                'name' => 'ふが',
            ],
            [
                'id' => 4,
                'client_id' => 'fugafuga',
                'name' => 'ふがふが',
            ],
        ], $res);

        $search = 'dahodajdadaoi';
        $res = $this->client->fetchNonTestClientType($type, $search);
        $this->assertEquals([], $res);

        $type = 'ultra';
        $search = 'h';
        $res = $this->client->fetchNonTestClientType($type, $search);
        $this->assertEquals([], $res);
    }

    public function test_setTestFlag()
    {
        $res = $this->client->setTestFlag(1);
        $this->assertTrue($res);
        $res = $this->client->setTestFlag(5);
        $this->assertTrue($res);

        $this->expectException(Exception::class);
        $this->client->setTestFlag(10);
    }
}

```

```Client.php
<?php

namespace App\略\Client;

use Exception;
use App\Models\Client;
use Throwable;

class Client
{
    public function __construct(private $client = new Client){}

    /**
     *
     * @param string $type
     * @param string $search
     * @return array
     */
    public function fetchNonTestClientType(string $type, string $search): array
    {
        try {
            $res = $this->clientModel->select('id', 'client_id', 'name')
                ->where('type', $type)
                ->where(function($query) use ($search){
                    $query->where('client_id', 'like', "%$search%")
                        ->orWhere('name', 'like', "%$search%");
                })
                ->where('test_flag', '<>', 1)
                ->get()
                ->toArray();
        } catch (Throwable $e) {
            throw new Exception($e, 500);
        }
        return $res;
    }

    /**
     *
     * @param integer $id
     * @return boolean
     */
    public function setTestFlag(int $id): bool
    {
        try {
            $client = $this->client->findOrFail($id);
            $client->test_flag = 1;
            $res = $client->save();
        } catch (Throwable $e) {
            throw new Exception($e, 500);
        }
        return $res;
    }
```

長々とファイルを書きましたが簡単に説明すると
1. `fetchNonTestClientType()`は`Client`から条件にあったレコードを取得して`toArray()`して返す。
2. `setTestFlag()`は渡された引数の`id`のtest_flagを`1`書き換えて`save()`して実行結果を返す。
という事を行なっています。
3. `test_fetchNonTestClientType()`は`$type`と`$search`を投げて検索結果が想定通りのものか確認するテスト
4. `test_setTestFlag()`は指定した`$id`のレコードが上書きに成功したか、`$id`が見つからずExceptionを発行したかどうかを確認するテスト

となっています。

## テスト実行
やっとテスト実行の準備ができました。
まずはテストを個別に実行します。
```terminal
$ php artisan test tests/Unit/ClientTest.php --filter test_fetchNonTestClientType

   PASS  Tests\Unit\ClientTest
  ✓ fetch non test client type

  Tests:  1 passed
  Time:   0.91s

$ php artisan test tests/Unit/ClientTest.php --filter test_setTestFlag

   PASS  Tests\Unit\ClientTest
  ✓ set test flag

  Tests:  1 passed
  Time:   0.89s
```
うんうん、いい感じ。

それでは本命の`ClientTest.php`をまとめて実行してみます。

```terminal
$ php artisan test tests/Unit/ClientTest.php

   FAIL  Tests\Unit\ClientTest
  ✓ fetch test client type
  ⨯ set test flag

  ---

  • Tests\Unit\ClientTest > set test flag
   Exception

  Illuminate\Database\Eloquent\ModelNotFoundException: No query results for model [App\Models\Client] 1 in /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:420
Stack trace:
#0 /var/www/vendor/laravel/framework/src/Illuminate/Support/Traits/ForwardsCalls.php(23): Illuminate\Database\Eloquent\Builder->findOrFail(1)
#1 /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php(1890): Illuminate\Database\Eloquent\Model->forwardCallTo(Object(Illuminate\Database\Eloquent\Builder), 'findOrFail', Array)
#2 /var/www/app/略/Client.php(361): Illuminate\Database\Eloquent\Model->__call('findOrFail', Array)
#3 /var/www/tests/Unit/ClientTest.php(91): App\略\Client->setTestFlag(1)
#4 /var/www/vendor/phpunit/phpunit/src/Framework/TestCase.php(1527): Tests\Unit\ClientTest->test_setTestFlag()
#5 /var/www/vendor/phpunit/phpunit/src/Framework/TestCase.php(1133): PHPUnit\Framework\TestCase->runTest()
#6 /var/www/vendor/phpunit/phpunit/src/Framework/TestResult.php(722): PHPUnit\Framework\TestCase->runBare()
#7 /var/www/vendor/phpunit/phpunit/src/Framework/TestCase.php(885): PHPUnit\Framework\TestResult->run(Object(Tests\Unit\ClientTest))
#8 /var/www/vendor/phpunit/phpunit/src/Framework/TestSuite.php(678): PHPUnit\Framework\TestCase->run(Object(PHPUnit\Framework\TestResult))
#9 /var/www/vendor/phpunit/phpunit/src/TextUI/TestRunner.php(670): PHPUnit\Framework\TestSuite->run(Object(PHPUnit\Framework\TestResult))
#10 /var/www/vendor/phpunit/phpunit/src/TextUI/Command.php(143): PHPUnit\TextUI\TestRunner->run(Object(PHPUnit\Framework\TestSuite), Array, Array, true)
#11 /var/www/vendor/phpunit/phpunit/src/TextUI/Command.php(96): PHPUnit\TextUI\Command->run(Array, true)
#12 /var/www/vendor/phpunit/phpunit/phpunit(92): PHPUnit\TextUI\Command::main()
#13 {main}

  at app/略/Client/Client.php:365
    361▕             $client = $this->client->findOrFail($id);
    362▕             $client->test_flag = 1;
    363▕             $res = $client->save();
    364▕         } catch (Throwable $e) {
  ➜ 365▕             throw new Exception($e, 500);
    366▕         }
    367▕         return $res;
    368▕     }
    369▕ }

  1   tests/Unit/ClientTest.php:91
      App\略\Client::setTestFlag()


  Tests:  1 failed, 1 passed
  Time:   1.04s

```

エラーを見る限り`test_setTestFlag`で`$this->expectException(Exception::class);`を宣言？する前にExceptionが発行されてしまっている様です。個別のテストでうまくいってるのになんだこれと思って色々試してみました。


1. `test_fetchNonTestClientType()`と`test_setTestFlag()`の順序を変えてみた。
2. `primaryKey`を指定しながらseedしてみた。(seederのコメントアウトを解除)
3. `test_setTestFlag()`の先頭で`$this->expectException(Exception::class);`を追加

```terminal
　// 1の結果
$　php artisan test tests/Unit/ClientTest.php

   FAIL  Tests\Unit\ClientTest
  ✓ set test flag
  ⨯ fetch test client type

  ---

  • Tests\Unit\ClientTest > fetch test client type
  Failed asserting that two arrays are equal.

  at tests/Unit/ClientTest.php:40
     36▕         $search = 'h';
     37▕         $res = $this->client->fetchNonTestClientType($type, $search);
     38▕         $this->assertEquals([
     39▕             [
  ➜  40▕                 'id' => 1,
     41▕                 'client_id' => 'hoge',
     42▕                 'name' => 'ほげ',
     43▕             ],
     44▕             [
  --- Expected
  +++ Actual
  @@ @@
   Array (
       0 => Array (
  -        'id' => 1
  +        'id' => 6
           'client_id' => 'hoge'
           'name' => 'ほげ'
       )
       1 => Array (
  -        'id' => 2
  +        'id' => 7
           'client_id' => 'hogehoge'
           'name' => 'ほげほげ'
       )
   )

  Tests:  1 failed, 1 passed
  Time:   1.01s


// 2の結果
$ php artisan test tests/Unit/ClientTest.php

   PASS  Tests\Unit\ClientTest
  ✓ fetch fetch test client type
  ✓ set test flag

  Tests:  2 passed
  Time:   1.00s


// 3の結果
$ php artisan test tests/Unit/ClientTest.php

   PASS  Tests\Unit\ClientTest
  ✓ fetch fetch test client type
  ✓ set test flag

  Tests:  2 passed
  Time:   1.00s

```
それぞれの結果を見ると、`auto increment`している`id`が違っていることによりエラーになっていたことがわかりました。
これらを見るに今のテストの設定だと１つのテストメソッド毎にseedされている？という疑問が浮上したのでテストクラスに１つ実験用メソッドを追加しました。

```ClientTest.php
public function test_hoge()
{
    $type = 'sf';
    $search = 'h';
    $res = $this->client->fetchNonTestClientType($type, $search);
    $this->assertEquals([
        [
            'id' => 1,
            'client_id' => 'hoge',
            'name' => 'ほげ',
        ],
        [
            'id' => 2,
            'client_id' => 'hogehoge',
            'name' => 'ほげほげ',
        ],
    ], $res);
}
```

```terminal
php artisan test tests/Unit/ClientTest.php

   FAIL  Tests\Unit\ClientTest
  ✓ fetch test client type
  ✓ set test flag　　// 先頭でExceptionを許容
  ⨯ hoge

  ---

  • Tests\Unit\ClientTest > hoge
  Failed asserting that two arrays are equal.

  at tests/Unit/ClientTest.php:109
    105▕         $search = 'h';
    106▕         $res = $this->client->fetchNonTestClientType($type, $search);
    107▕         $this->assertEquals([
    108▕             [
  ➜ 109▕                 'id' => 1,
    110▕                 'client_id' => 'hoge',
    111▕                 'name' => 'ほげ',
    112▕             ],
    113▕             [
  --- Expected
  +++ Actual
  @@ @@
   Array (
       0 => Array (
  -        'id' => 1
  +        'id' => 11
           'client_id' => 'hoge'
           'name' => 'ほげ'
       )
       1 => Array (
  -        'id' => 2
  +        'id' => 12
           'client_id' => 'hogehoge'
           'name' => 'ほげほげ'
       )
   )

  Tests:  1 failed, 2 passed
  Time:   1.13s
```
予想通りテストメソッド毎にseedされているようです

## さいごに
`setUp()`と`RefreshDatabase`が全てのテストメソッド実行前ではなくテスト実行前にだけ実行されると思い込んでいたことが原因でこういった事象が起こりました。今回の解決案としては
1. seederを書く時はautoincrements指定されているカラムもきちんと書いてやる
2. seedするのはartisanコマンドで事前にやってもらい、RefreshDatabaseを指定しないことで採番が変わらない様にする。

今回は１を採用することにしました。

### 余談
ほんとは未解決ってタイトルにしようと思ってましたが、前日寝ようとしてる時にふと思いついて解決してしまいました。(よくあるよね)