Laravelテストコードのサンプル



## Environment

- PHP 8.2
- Laravel 10.14
- laravel sail

## Code

### Test準備

まずはテスト実行用のDBを用意。

- 環境変数の設定

`.env.tsting` ファイルを作成して以下の内容を設定。他の設定はそれぞれの環境に合わせる。

```bash
DB_DATABASE=testing
```

laravel sailでアプリを作成した場合、デフォルトでtestingデータベースが作成される。

`docker-compose.yml` のmysqlに以下の記載がある。

```yaml
volumes:
            - 'sail-mysql:/var/lib/mysql'
            - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
```

テスト実行に関する設定は `phpunit.xml` で定義されている。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory suffix=".php">./app</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="DB_DATABASE" value="testing"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
    </php>
</phpunit>
```

`<env name="APP_ENV" value="testing"/>` で `.env.testing` が読み込まれる。

- テスト実行

`php artisan test` を実行すると、`tests`フォルダ下のテストファイルが実行される。

## Testing

### using Class

`use RefreshDatabase`

テスト実行後にデータベースをリフレッシュする。

`use WithFaker`

以下のようにfakerを使えるようになる。

```php
User::create([
	'name' => $this->faker()->firstName();
]);
```

### setUpとtearDown

setUpメソッドをオーバーライドすることでクラス単位で必要な共通処理を記述して効率化できる。

tearDownメソッドをオーバーライドすることでテスト実施後に必要な処理を記述することができる。

```php
protected function setUp(): void
{
    parent::setUp();

		// テストの前処理
    $this->user = User::create([
        'name' => $this->faker()->name(),
        'email' => $this->faker()->email(),
        'password' => $this->faker()->password(),
    ]);
}

protected function tearDown(): void
{
    parent::tearDown();

		// テストの後処理
}
```

### using assertion

- 値

```php
// Emptyかどうかチェック
$this->assertEmpty('');
$this->assertNotEmpty('aaa');

// nullかどうかチェック
$this->assertnull(null);
$this->assertNotNull('');

// 値が一致する/しない (== : 等価演算子)
$this->assertEquals(1, 1);
$this->assertEquals(1, '1');
$this->assertNotEquals(1, 'a');

// 値が一致する/しない (=== : 厳密等価演算子)
$this->assertSame(1, 1);
$this->assertNotSame('1', 1);

// 指定の誤差を許容して一致するかチェック
$this->assertEqualsWithDelta(10.0, 10.10, 0.1);
$this->assertNotEqualsWithDelta(10.0, 10.11, 0.1);

// 順番を区別せずに要素が一致するかチェック
$this->assertEqualsCanonicalizing(['a', 'b', 'c'], ['c', 'a', 'b']);
$this->assertNotEqualsCanonicalizing(['a', 'b', 'c'], ['a', 'b', 'd']);

// 大文字小文字を区別せずに要素が一致するかチェック
$this->assertEqualsIgnoringCase('HELLO WORLD', 'hello world');
$this->assertNotEqualsIgnoringCase('HELLO WORLD', 'hell world');

// 正規表現に一致するかチェック
$pattern = '/[0-9]/';
$this->assertMatchesRegularExpression($pattern, 123);
$this->assertDoesNotMatchRegularExpression($pattern, 'Hello');

// 有理数かチェック
$this->assertFinite(100);
// 無理数かチェック
$this->assertInfinite(INF);

// より大きいかチェック <
$this->assertGreaterThan(100, 200);
// 以上かチェック <=
$this->assertGreaterThanOrEqual(100, 100);
// より小さいかチェック >
$this->assertLessThan(100, 50);
// 以下かチェック >=
$this->assertLessThanOrEqual(100, 100);

// Nanかチェック
$this->assertNan(sqrt(-4));
```

- 配列

```php
// 配列にキーが存在する/しない
$array = [
            'apple' => 'リンゴ',
            'banana' => 'バナナ',
        ];
$this->assertArrayHasKey('apple', $array);
$this->assertArrayNotHasKey('orange', $array);

// 配列に特定の値を含む/含まない (=== : 厳密比較演算子)
$numbers = [1, 2, 2, 3, 3, 3];
$this->assertContains(1, $numbers);
$this->assertNotContains('1', $numbers);

// 配列に特定の値を含む/含まない (== : 比較演算子)
$numbers = [1, 2, 2, 3, 3, 3];
$this->assertContainsEquals(1, $numbers);
$this->assertContainsEquals('1', $numbers);
$this->assertNotContainsEquals(5, $numbers);

// 配列が特定の型のみを含む/含まない
$numbers = [1, 2, 2, 3, 3, 3];
$this->assertContainsOnly('int', $numbers);
$this->assertNotContainsOnly('string', $numbers);

// 配列が特定のインスタンスのみを含む
$carbons = [new Carbon(), new Carbon(), new Carbon()];
$this->assertContainsOnlyInstancesOf(Carbon::class, $carbons);

// Countable|iterableの要素数の数をチェック
$carbons = [new Carbon(), new Carbon(), new Carbon()];
$this->assertCount(3, $carbons);
$this->assertNotCount(4, $carbons);
```

- データベース

```php
// DBのテーブルが空かチェック
$this->assertDatabaseEmpty('users');

// DBのテーブルの要素数をチェック
$user = User::create([
    'name' => $this->faker()->firstName(),
    'email' => $this->faker()->email(),
    'password' => Hash::make('password'),
]);
$this->assertDatabaseCount('users', 1);

// DBのテーブルに特定のレコードが存在する/しないをチェック
$this->assertDatabaseHas('users', [
    'name' => $user->name,
    'email' => $user->email,
    'password' => $user->password,
]);
$this->assertDatabaseMissing('users', [
    'name' => 'missing',
]);

// DBに指定のモデルが存在するかチェック
$this->assertModelExists($user);
$user->delete();
$this->assertModelMissing($user);
// モデルがソフトデリートされているかチェック
$this->assertSoftDeleted();
```

- 権限

```php
// 指定のAuthenticatableユーザとして実行する
$this->actingAs($user);

// 現在のユーザに権限があるかチェックする
$this->assertAuthenticated('web');

// 指定のユーザに指定の権限があるかチェックする
$this->assertAuthenticatedAs($user, 'web');
```

- ディレクトリ

```php
// ディレクトリが存在するかチェック
$this->assertDirectoryExists('./public');
$this->assertDirectoryDoesNotExist('./public/unknown');

// ディレクトリが読取り/書込み 可能/不可能をチェック
$this->assertDirectoryIsReadable('./readable');
$this->assertDirectoryIsNotReadable('./unreadable');
$this->assertDirectoryIsWritable('./writable');
$this->assertDirectoryIsNotWritable('./unwritable');
```

- ファイル

```php
// ファイル存在チェック
$this->assertFileExists('storage/test/test_lower.txt');
$this->assertFileDoesNotExist('storage/test/test.txt');

// ファイル内容が一致するかチェック
$this->assertFileEquals('storage/test/test_lower.txt', 'storage/test/test_lower.txt');
$this->assertFileNotEquals('storage/test/test_lower.txt', 'storage/test/test_upper.txt');

// 順番を無視してファイル内容が一致するかチェック
$this->assertFileEqualsCanonicalizing('storage/test/test_a.txt', 'storage/test/test_a2.txt');
$this->assertFileNotEqualsCanonicalizing('storage/test/test_a.txt', 'storage/test/test_b.txt');

// 大文字小文字を無視してファイル内容が一致するかチェック
$this->assertFileEqualsIgnoringCase('storage/test/test_lower.txt', 'storage/test/test_upper.txt');
$this->assertFileNotEqualsIgnoringCase();

// ファイルが読み書き可能かチェック
$this->assertFileIsReadable('storage/test/test_lower.txt');
$this->assertFileIsNotReadable();
$this->assertFileIsWritable('storage/test/test_lower.txt');
$this->assertFileIsNotWritable();
```

- 型

```php
// 特定のクラスのインスタンスかチェック
$class = new stdClass();
$this->assertInstanceOf(stdClass::class, $class);

// arrayかチェック
$this->assertIsArray(['a']);
$this->assertIsNotArray('a');

// boolかチェック
$this->assertIsBool(true);
$this->assertIsNotBool('a');

// trueかチェック
$this->assertTrue(true);
$this->assertNotTrue(false);

// falseかチェック
$this->assertFalse(false);
$this->assertNotFalse(true);

// callableかチェック
$callback = function () {
  return 'test';
};
$this->assertIsCallable($callback);
$this->assertIsNotCallable('a');

// floatかチェック
$this->assertIsFloat(1.0);
$this->assertIsNotFloat(1);
// intかチェック
$this->assertIsInt(1);
$this->assertIsNotInt('a');

// iterableかチェック
$this->assertIsIterable(['a']);
$this->assertIsNotIterable('a');

// listかチェック
$this->assertIsList(['a', 'b']);

// numericかチェック
$this->assertIsNumeric(1);
$this->assertIsNotNumeric('a');

// objかチェック
$obj = new stdClass();
$this->assertIsObject($obj);
$this->assertIsNotObject('a');

// resourceかチェック
$file = fopen('storage/test/test_a.txt', 'r');
$this->assertIsResource($file);
$this->assertIsNotResource('a');

// scalarかチェック
$this->assertIsScalar(1);
$this->assertIsNotScalar(['a', 'b']);

// stringかチェック
$this->assertIsString('1');
$this->assertIsNotString(1);
```

- JSON

```php
$user = User::create([
    'name' => $this->faker()->firstName(),
    'email' => $this->faker()->email(),
    'password' => Hash::make('password'),
]);
// JSONレスポンス取得
$response = $this->getJson('/user/' . $user->id);
// ステータス、JSONレスポンスjチェック
$response
    ->assertStatus(200)
    ->assertJson([
        'name' => $user->name,
        'email' => $user->email,
        'password' => $user->password,
    ]);

// jsonファイルが一致するかチェック
$this->assertJsonFileEqualsJsonFile('storage/test/test_json_a.json', 'storage/test/test_json_a.json');
$this->assertJsonFileNotEqualsJsonFile('storage/test/test_json_a.json', 'storage/test/test_json_b.json');

// jsonファイルとjson文字列が一致するかチェック
$this->assertJsonStringEqualsJsonFile('storage/test/test_json_a.json', '{ "a2": "aa", "a1": "a" }');
$this->assertJsonStringNotEqualsJsonFile('storage/test/test_json_a.json', '{ "b1": "b" }');

// json文字列が一致するかチェック
$this->assertJsonStringEqualsJsonString('{ "a1": "a", "a2": "aa" }', '{ "a2": "aa", "a1": "a" }');
$this->assertJsonStringNotEqualsJsonString('{ "a": "aaa" }', '{ "b": "bbb" }');
```

- 文字列

```php
// 文字列が特定の文字列を含むかチェック
$this->assertStringContainsString('a', 'abc');
$this->assertStringNotContainsString('d', 'abc');

// 文字列が大文字小文字を区別せずに特定の文字列を含むかチェック
$this->assertStringContainsStringIgnoringCase('a', 'ABC');
$this->assertStringNotContainsStringIgnoringCase('d', 'ABC');

// 文字列が特定の文字列で始まっているかチェック
$this->assertStringStartsWith('Hello', 'Hello World!');
$this->assertStringStartsNotWith('Goodbey', 'Hello World!');

// 文字列が特定の文字列で終わっているかチェック
$this->assertStringEndsWith('World!', 'Hello World!');
$this->assertStringEndsNotWith('Tom', 'Hello world!');

// 文字列が文字列がファイルの内容と一致するかチェック
$this->assertStringEqualsFile('storage/test/test_a.txt', 'a b');
$this->assertStringNotEqualsFile('storage/test/test_a.txt', 'a c');

// 文字列が大文字小文字を区別せずにファイルの内容と一致するかチェック
$this->assertStringEqualsFileIgnoringCase('storage/test/test_a.txt', 'A B');
$this->assertStringNotEqualsFileIgnoringCase('storage/test/test_a.txt', 'A C');

// 文字列がフォーマットと一致するかチェック
$this->assertStringMatchesFormat('Hello, %s!', 'Hello, Tim!');
$this->assertStringNotMatchesFormat('Hello, %i!', 'Hello, Tim!');

// 文字列がファイルの内容のフォーマットと一致するかチェック
$this->assertStringMatchesFormatFile('storage/test/test_format_s.txt', 'Hello John!');
$this->assertStringNotMatchesFormatFile('storage/test/test_format_i.txt', 'Hello john!');
```

- 関数

```php
// 指定の関数の条件を満たすかチェック
$constraint = static::callback(function ($num) {
    return $num > 10;
});
$this->assertThat(100, $constraint);

// 指定の関数が指定したクラスをThrowするかチェック
$function = function () {
    throw new Exception();
};
$this->assertThrows($function, Exception::class);
```

- API

```php
$response = $this->withHeaders([
    'X-Header' => 'Value',
])->post('/user', [
    'name' => 'Sally',
    'email' => 'hello@example.com',
    'password' => 'password',
]);

$response->assertStatus(201);
```

- DataProivder

phpdocにデータプロバイダーの設定を記述することで、同じテストに対して異なるパラメータで複数回実行できる。

```php
/**
 * @dataProvider dataProvider
 * @param mixed $int1
 * @param mixed $int2
 * @return void
 */
public function testSame($int1, $int2)
{
    $this->assertSame($int1, $int2);
}

public function dataProvider()
{
    return [
        [0, 0],
        [1, 1],
        [2, 2],
        [3, 3],
    ];
}
```

- mock

指定のクラス、関数をモック化することで、テスト範囲を限定することができる。

SampleController.php

```php
protected SampleService $sample_service;

public function __construct(SampleService $service)
{
    $this->sample_service = $service;
}

public function calcEach(int $init_num)
{
    $num = $this->sample_service->plus($init_num, 2);
    $num = $this->sample_service->multiple($num, 3);
    $num = $this->sample_service->minus($num, 4);
    $num = $this->sample_service->devide($num, 5);

    return $num;
}
```

SampleService.php

```php
<?php

namespace App\Http\Services;

use Illuminate\Http\Request;

class SampleService
{
    public function plus(int $num1, int $num2)
    {
        return $num1 + $num2;
    }

    public function multiple(int $num1, int $num2)
    {
        return $num1 * $num2;
    }

    public function minus(int $num1, int $num2)
    {
        return $num1 - $num2;
    }

    public function devide(int $num1, int $num2)
    {
        return $num1 / $num2;
    }
}
```

Test

```php
public function test_calc_mock()
{
		// SampleServiceの各メソッドをモック化
    $mock = Mockery::mock(SampleService::class, function (MockInterface $mock) {
        $mock->shouldReceive('plus')->once()->andReturn(3);
        $mock->shouldReceive('multiple')->once()->andReturn(9);
        $mock->shouldReceive('minus')->once()->andReturn(5);
        $mock->shouldReceive('devide')->once()->andReturn(1);
    });

		// controllerからcalcEach呼び出し
    // モック化されたserviceのplus, multiple, minus, devideは定数を返す
    $controller = new SampleController($mock);
    $res = $controller->calcEach(1);

    $this->assertEquals(1, $res);
}
```

## Conclusion
以上、基本的なテストコードについてまとめてみました。
Laravel Duskを使ったテストについても勉強したいですね。