【Laravel + PHPUnit】Unit・Featureテストを実装してGitHubActionsでCI/CD環境を構築する話

# 概要

`PHPUnit`を利用した`CI/CD`環境の構築までをまとめます

# 環境

- PHP 7.4.25
- Laravel Framework 6.20.44

# `Factory` でテストデータを作成する

https://readouble.com/laravel/6.x/ja/database-testing.html

## `Factory`クラスを作成

```bash
php artisan make:factory UserFactory
```

## `Factory`クラスを定義

https://github.com/fzaninotto/Faker

```php:src/laravel/database/factories/UserFactory.php
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Models\User;
use Faker\Generator as Faker;
use Illuminate\Support\Str;

/*
|--------------------------------------------------------------------------
| Model Factories
|--------------------------------------------------------------------------
|
| This directory should contain each of the model factory definitions for
| your application. Factories provide a convenient way to generate new
| model instances for testing / seeding your application's database.
|
*/

$factory->define(User::class, function (Faker $faker) {
    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'email_verified_at' => now(),
        'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
        'remember_token' => Str::random(10),
    ];
});
```

# 認証機能の対象のモデルを編集

`Authenticatable` クラスを継承します。

後述するテストコードで`actingAs()`メソッドを利用した認証時のエラー対策です。

> TypeError: Argument 1 passed to Illuminate\Foundation\Testing\TestCase::actingAs() must be an instance of Illuminate\Contracts\Auth\Authenticatable, instance of App\Models\User given


```php:src/laravel/app/Models/User.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use SoftDeletes;

    /**
     * モデルと関連しているテーブル
     *
     * @var string
     */
    protected $table = 'users';
   
    protected $fillable = [
        'name',
        'email',
        'email_verified_at',
        'password',
    ];
}
```

# `Unit`テストを実装

## テスト対象クラス

```php:app/Domains/Master.php
class Sample implements Master
{
    private int $id;

    public const ROLES = [
        1 => 'hoge',
        2 => 'fuga',
    ];

    public static function getNameById(int $id): string
    {
        return self::ROLES[$id];
    }

    public static function getIdByName(string $name): int
    {
        return array_search($name, self::ROLES);
    }

    /**
     * @throws BadRequestException
     */
    public function __construct(int $id)
    {
        $max = count(self::ROLES);

        if ($id < 1) {
            throw new BadRequestException('idの値が1以下です');
        }

        if ($id > $max) {
            throw new BadRequestException("idの値が{$max}以上です");
        }

        $this->id = $id;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return self::getNameById($this->id);
    }
}
```

## テスト


```php:src/laravel/tests/Unit/SampleTest.php
use PHPUnit\Framework\TestCase;

class SampleTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
    }

    public function test_NG_コンストラクタに引数を渡していない()
    {
        $this->expectException(\ArgumentCountError::class);
        new UserRole();
    }

    public function test_OK_getId()
    {
        $id = 1;
        $userRole = new UserRole($id);
        $this->assertEquals($id, $userRole->getId());
    }

    public function test_OK_getName()
    {
        $id = 1;
        $name = UserRole::getNameById($id);
        $userRole = new UserRole($id);
        $this->assertEquals($name, $userRole->getName());
    }
}
```

## テスト実行

```bash
./vendor/bin/phpunit tests/Unit/

Time: 00:00.145, Memory: 8.00 MB

OK (3 tests, 3 assertions)
```

# `Feature`テストを実装

https://readouble.com/laravel/6.x/ja/http-tests.html

```php:src/laravel/tests/Feature/UserControllerTest.php
<?php

namespace Tests\Feature\Admin\Users;

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

class UserControllerTest extends TestCase
{
    // テスト実行ごとにDBをリフレッシュ
    use RefreshDatabase;

    public const INDEX_PATH = 'admin/users';
    public const INDEX_VIEW_NAME = 'admin.users.index';
    public const SHOW_VIEW_NAME = 'admin.users.show';
    public const EDIT_VIEW_NAME = 'admin.users.edit';

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

        // POST時に419エラーが発生するのでCSRFミドルウェアを無効にする
        $this->withoutMiddleware([VerifyCsrfToken::class]);

        // テストデータを作成
        $this->authUser = factory(User::class)->create();
        $this->unAuthUser = factory(User::class)->create();
    }

    public function test_NG_ログインしていない場合はアクセスできない()
    {
        $response = $this->get(self::INDEX_PATH);
        $response->assertStatus(302);
    }

    public function test_OK_一覧画面にアクセス()
    {
        $response = $this->actingAs($this->authUser)->get(self::INDEX_PATH);
        $response->assertOk();
        $response->assertViewIs(self::INDEX_VIEW_NAME);
    }

    public function test_OK_検索()
    {
        $name = User::first()->name;
        $path = "admin/users?name=${name}";
        $response = $this->actingAs($this->authUser)->get($path);
        $response->assertOk();
        $response->assertSee($name);
        $response->assertViewIs(self::INDEX_VIEW_NAME);
    }

    public function test_OK_詳細()
    {
        $id = User::query()->first()->id;
        $path = "admin/users/${id}";
        $response = $this->actingAs($this->authUser)->get($path);
        $response->assertOk();
        $response->assertViewIs(self::SHOW_VIEW_NAME);
    }

    public function test_OK_登録()
    {
        $path = "admin/users/edit";
        $response = $this->actingAs($this->authUser)->get($path);
        $response->assertOk();
        $response->assertViewIs(self::EDIT_VIEW_NAME);
    }

    public function test_OK_編集()
    {
        $id = User::query()->first()->id;
        $path = "admin/users/edit/${id}";
        $response = $this->actingAs($this->authUser)->get($path);
        $response->assertOk();
        $response->assertViewIs(self::EDIT_VIEW_NAME);
    }

    public function test_OK_登録成功()
    {
        $path = "admin/users/store";
        $response = $this->actingAs($this->authUser)
            ->post(
                $path,
                [
                    "name" => '登録テスト',
                    "email" => "test@example.com",
                    "password" => 'password',
                ]
            );
        $response->assertOk();
        $this->assertDatabaseHas('users', ['name' => '登録テスト']);
    }

    public function test_OK_更新成功()
    {
        $path = "admin/users/store";
        $response = $this->actingAs($this->authUser)
            ->post(
                $path,
                [
                    "name" => '更新テスト',
                    "email" => "test@example.com",
                    "password" => 'password',
                    'base_user_id' => $this->authUser->id,
                ]
            );
        $response->assertOk();
        $this->assertDatabaseHas('users, ['name' => '更新テスト']);
    }
}
```

## テスト実行

```bash
./vendor/bin/phpunit tests/feature/

Time: 00:00.145, Memory: 8.00 MB

OK (8 tests, 8 assertions)
```

# `GitHubActions`にワークフローを定義

```yml:.github/workflows/main.yml
ame: CodeCheck

on:
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: dokcer-compose up
        run: |
          # docker-compose build
          docker-compose up -d
      - name: composer install
        run: |
          docker-compose exec -T laravel bash -c "composer install"
      - name: phpunit
        run: |
          docker-compose exec -T laravel bash -c "php vendor/bin/phpunit"
```