LaraveでCRUD処理をレイヤード・アーキテクチャで書いてみて、単体(Unit)テスト、機能(Feature)テストも書いてみる。

# 目次
- [概要](#概要)
- [開発環境](#開発環境)
- [環境構築](#環境構築)
- [Book プログラムの構成](#book-プログラムの構成)
- [本体プログラムのコード](#本体プログラムのコード)
- [単体（Unit）テストのコード](#単体unitテストのコード)
- [機能（Feature）テストのコード](#機能featureテストのコード)

# 概要
本（Book）のCRUD処理をLaravelを使って、レイヤード・アーキテクチャで書いてみました。
環境構築には、Laravel Sail を使います。

# 開発環境
OS
- Windows11上のWSL2で動作するUbuntu

以下がインストールされている事
- Composer v2以上

# 環境構築
```shell
# Laravel をインストール（プロジェクト名：myapp）
$ composer create-project --prefer-dist laravel/laravel myapp

# Laravel Sail をインストール
$ cd myapp
$ composer require laravel/sail --dev

# Laravel Sail の設定。必要なサービスをSpaceキーで選択してEnter（※mysql, redis を選択しておく）
$ php artisan sail:install
 ┌ Which services would you like to install? ───────────────────┐
 │ › ◻ mysql                                                  ┃ │
 │   ◻ pgsql                                                  │ │
 │   ◻ mariadb                                                │ │
 │   ◻ mongodb                                                │ │
 │   ◻ redis                                                  │ │
 └────────────────────────────────────────────────── 0 selected ┘
```

.envを以下のように変更します。
```shell:.env
　：
APP_LOCALE=ja          # jaに変更
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ja_JP # ja_JPに変更
　：
SESSION_DRIVER=redis   # redisに変更
　：
CACHE_DRIVER=redis     # 追加
　：
```

Laravel Sail では最初からテスト用の DB「testing」が用意されているので、.env を基にテスト用の環境設定ファイルを作成します。

```shell
$ cp .env .env.testing
```

.env.testing の内容を以下のように変更します。
```shell:.env.testing
APP_ENV=testing        # testingに変更
DB_DATABASE=testing    # testingに変更
```

phpunit.xml も以下のように修正します。

```xml:phpunit.xml
<env name="DB_DATABASE" value=":memory:"/>
                                 ↓
<env name="DB_DATABASE" value="testing"/>
```

Laravel Sail を起動します。

```shell
$ ./vendor/bin/sail up -d
```

DB のマイグレーションを実施します。

```shell
$ ./vendor/bin/sail artisan migrate               # ローカル用
$ ./vendor/bin/sail artisan migrate --env=testing # テスト用
```

ブラウザで http://localhost/ にアクセスし、Laravel の画面が表示できれば成功です。

# Book プログラムの構成
レイヤードアーキテクチャの各層と、ディレクトリの関係は以下です。
```shell
app/
├── Http/
│   └── Controllers/
│       └── BookController.php       ← (1) プレゼンテーション層
├── Services/
│   └── BookService.php              ← (2) アプリケーション層
├── Repositories/
│   ├── BookRepositoryInterface.php  ← (3) リポジトリ層（インターフェース）
│   └── BookRepository.php           ← (4) リポジトリ層（実装）
├── Models/
│   └── Book.php                     ← (5) ドメイン層（Eloquentモデル）※php artisanコマンドで作成
```

# 本体プログラムのコード
## カスタム例外処理の作成
まず先に例外処理を作ります。
```shell
$ php artisan make:exception BookNotFoundException
```

```php:app/Exceptions/BookNotFoundException.php
<?php

namespace App\Exceptions;

use Exception;

class BookNotFoundException extends Exception
{
    protected $message = '本がみつかりません';
}
```

## (5) のドメイン層とマイグレーション作成

以下コマンドで Book モデルとマイグレーションを作成します。
```shell
$ php artisan make:model Book -m
```
Bookモデルはの内容は以下となります。
```php:app/Models/Book.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Book extends Model
{
    use HasFactory; // Laravel 8以降では、Book モデルでファクトリを利用するためには、HasFactory トレイトを使用する必要があります
    protected $fillable = ['title', 'author', 'description']; // 一括代入(fillable)を設定
}
```
マイグレーションファイルの内容は以下となります。
```php:app/database/migrations/xxxx_xx_xx_xxxxxx_create_books_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('books', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('author');
            $table->text('description')->nullable();
            $table->timestamps();
        });
    }

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

以下コマンドで、Bookテーブルのマイグレーションを実施します。

```shell
$ ./vendor/bin/sail artisan migrate               # ローカル用
$ ./vendor/bin/sail artisan migrate --env=testing # テスト用
```

## (3)(4) の Repository 層のインターフェース＆実装

Repositories ディレクトリと、インターフェースのファイルを作成します。

```shell
$ mkdir -p app/Repositories
$ touch app/Repositories/BookRepositoryInterface.php #インターフェース
```

インターフェースの内容は以下となります。

```php:app/Repositories/BookRepositoryInterface.php
<?php
namespace App\Repositories;

use App\Models\Book;
use Illuminate\Support\Collection;

interface BookRepositoryInterface
{
    public function all(): Collection;
    public function find(int $id): Book;
    public function create(array $data): Book;
    public function update(Book $book, array $data): bool;
    public function delete(Book $book): bool;
}
```

実装のファイルを作成します。

```shell
touch app/Repositories/BookRepository.php # 実装
```

実装の内容は以下となります。

```php:app/Repositories/BookRepository.php
<?php
namespace App\Repositories;

use App\Models\Book;
use Illuminate\Support\Collection;

class BookRepository implements BookRepositoryInterface
{
    public function all(): Collection
    {
        return Book::all();
    }

    public function find(int $id): Book
    {
        return Book::findOrFail($id);
    }

    public function create(array $data): Book
    {
        return Book::create($data);
    }

    public function update(Book $book, array $data): bool
    {
        return $book->update($data);
    }

    public function delete(Book $book): bool
    {
        return $book->delete();
    }
}
```

## (2) のアプリケーション層の実装

Services ディレクトリと、Service ファイルを作成します。

```shell
$ mkdir app/Services
$ touch app/Services/BookService.php
```

Service ファイルの内容は以下となります。

```php:app/Services/BookService.php
<?php
namespace App\Services;

use App\Models\Book;
use App\Repositories\BookRepositoryInterface;
use App\Exceptions\BookNotFoundException;
use Illuminate\Support\Collection;

class BookService
{
    public function __construct(
        protected BookRepositoryInterface $bookRepository
    ) {}

    public function getAll(): Collection
    {
        return $this->bookRepository->all();
    }

    public function find(int $id): Book
    {
        try {
            return $this->bookRepository->find($id);
        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            throw new BookNotFoundException();
        }
    }

    public function create(array $data): Book
    {
        return $this->bookRepository->create($data);
    }

    public function update(Book $book, array $data): bool
    {
        return $this->bookRepository->update($book, $data);
    }

    public function delete(Book $book): bool
    {
        return $this->bookRepository->delete($book);
    }
}
```

## (1) のプレゼンテーション層の実装

コントローラーを作成します。

```shell
$ php artisan make:controller BookController --resource # CRUDメソッドも自動作成
```

コントローラーの内容は以下となります。

```php:app/Http/Controllers/BookController.php
<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Exceptions\BookNotFoundException;
use App\Services\BookService;
use App\Models\Book;

class BookController extends Controller
{
    public function __construct(
        protected BookService $bookService
    ) {}

    public function index()
    {
        $books = $this->bookService->getAll();
        return view('books.index', compact('books'));
    }

    public function create()
    {
        return view('books.create');
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'title' => 'required|string',
            'author' => 'required|string',
            'description' => 'nullable|string',
        ]);

        $this->bookService->create($data);

        return redirect()->route('books.index')->with('success', 'Book created!');
    }

    public function show($id)
    {
        try {
            $book = $this->bookService->find($id);
            return view('books.show', compact('book'));
        } catch (BookNotFoundException $e) {
            return response($e->getMessage(), 404);
            // return redirect()->route('books.index')->with('error', $e->getMessage());
        }
    }

    public function edit($id)
    {
        $book = $this->bookService->find($id);
        return view('books.edit', compact('book'));
    }

    public function update(Request $request, $id)
    {
        $book = $this->bookService->find($id);

        $data = $request->validate([
            'title' => 'required|string',
            'author' => 'required|string',
            'description' => 'nullable|string',
        ]);

        $this->bookService->update($book, $data);

        return redirect()->route('books.index')->with('success', 'Book updated!');
    }

    public function destroy($id)
    {
        $book = $this->bookService->find($id);
        $this->bookService->delete($book);

        return redirect()->route('books.index')->with('success', 'Book deleted!');
    }
}
```

## サービスプロバイダでバインド

BookRepositoryInterface を必要としている場所には、BookRepository を渡すように Laravel に教えます。

```php:app/Providers/AppServiceProvider.php
use App\Repositories\BookRepositoryInterface; // 追加
use App\Repositories\BookRepository; // 追加
    ：
public function register(): void
{
    $this->app->bind(BookRepositoryInterface::class, BookRepository::class); // 追加
}
```

## Viewファイルの作成（必要最小限）

View 用 のディレクトリと、該当ファイルを作成します。

```shell
$ mkdir -p resources/views/books
$ touch resources/views/books/index.blade.php
$ touch resources/views/books/create.blade.php
$ touch resources/views/books/show.blade.php
$ touch resources/views/books/edit.blade.php
```

```php:resources/views/books/index.blade.php
<h1>本の一覧</h1>

@if (session('success'))
    <div style="color:green;">
        {{ session('success') }}
    </div>
@endif

<a href="{{ route('books.create') }}">新規作成</a>

<ul>
    @foreach ($books as $book)
        <li>
            <a href="{{ route('books.show', $book->id) }}">{{ $book->title }}</a>
            <a href="{{ route('books.edit', $book->id) }}">[編集]</a>
            <form action="{{ route('books.destroy', $book->id) }}" method="POST" style="display:inline">
                @csrf
                @method('DELETE')
                <button type="submit">削除</button>
            </form>
        </li>
    @endforeach
</ul>
```

```php:resources/views/books/create.blade.php
<h1>本の新規作成</h1>

<form method="POST" action="{{ route('books.store') }}">
    @csrf

    <div>
        <label>タイトル:</label><br>
        <input type="text" name="title" required>
    </div>

    <div>
        <label>著者:</label><br>
        <input type="text" name="author" required>
    </div>

    <div>
        <label>説明:</label><br>
        <textarea name="description" rows="4" required></textarea>
    </div>

    <button type="submit">登録</button>
</form>

<a href="{{ route('books.index') }}">← 一覧に戻る</a>
```

```php:resources/views/books/show.blade.php
<h1>本の詳細</h1>

<p><strong>タイトル:</strong> {{ $book->title }}</p>
<p><strong>著者:</strong> {{ $book->author }}</p>
<p><strong>説明:</strong> {{ $book->description }}</p>

<a href="{{ route('books.edit', $book->id) }}">編集</a>
<a href="{{ route('books.index') }}">一覧に戻る</a>
```

```php:resources/views/books/edit.blade.php
<h1>本の編集</h1>

<form method="POST" action="{{ route('books.update', $book->id) }}">
    @csrf
    @method('PUT')

    <div>
        <label>タイトル:</label><br>
        <input type="text" name="title" value="{{ $book->title }}" required>
    </div>

    <div>
        <label>著者:</label><br>
        <input type="text" name="author" value="{{ $book->author }}" required>
    </div>

    <div>
        <label>説明:</label><br>
        <textarea name="description" rows="4" required>{{ $book->description }}</textarea>
    </div>

    <button type="submit">更新</button>
</form>

<a href="{{ route('books.index') }}">← 一覧に戻る</a>
```

# 単体（Unit）テストのコード

ユニットテストなので、リポジトリ層とアプリケーション層の **メソッド単位** のテストを行います。
まず最初にテスト用データを生成するファクトリを作成します。

## ファクトリの作成

```shell
$ php artisan make:factory BookFactory --model=Book
```

ファクトリの内容は以下となります。

```php:database/factories/BookFactory.php
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class BookFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => $this->faker->sentence,
            'author' => $this->faker->name,
            'description' => $this->faker->paragraph,
        ];
    }
}
```

## リポジトリ層のテストコード

以下のコマンドで リポジトリ層の Unit テスト用ファイルの雛形を作成します。

```
$ php artisan make:test BookRepositoryTest --unit
```

作成された Unit テスト用のファイルを以下のようにしてください。

```php:tests/Unit/BookRepositoryTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;
use App\Models\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Repositories\BookRepository;
use App\Repositories\BookRepositoryInterface;

class BookRepositoryTest extends TestCase
{
    use RefreshDatabase;

    protected BookRepositoryInterface $bookRepo;

    protected function setUp(): void
    {
        parent::setUp();
        $this->bookRepo = new BookRepository();
    }

    public function test_allメソッドの確認()
    {
        Book::factory()->count(3)->create();

        $books = $this->bookRepo->all();

        $this->assertCount(3, $books);
    }

    public function test_createメソッドの確認()
    {
        $data = [
            'title' => '私の本',
            'author' => '山田太郎',
            'description' => '本の説明'
        ];

        $book = $this->bookRepo->create($data);

        $this->assertDatabaseHas('books', ['title' => '私の本']);
        $this->assertEquals('山田太郎', $book->author);
        $this->assertEquals('本の説明', $book->description);
    }

    public function test_findメソッドの確認()
    {
        $book = Book::factory()->create();

        $found = $this->bookRepo->find($book->id);

        $this->assertEquals($book->title, $found->title);
    }

    public function test_updateメソッドの確認()
    {
        $book = Book::factory()->create();

        $this->bookRepo->update($book, ['title' => '更新したタイトル']);

        $this->assertDatabaseHas('books', ['title' => '更新したタイトル']);
    }

    public function test_deleteメソッドの確認()
    {
        $book = Book::factory()->create();

        $this->bookRepo->delete($book);

        $this->assertDatabaseMissing('books', ['id' => $book->id]);
    }
}
```

## アプリケーション層のテスト

以下のコマンドで アプリケーション層の Unit テスト用ファイルの雛形を作成します。

```shell
$ php artisan make:test BookServiceTest --unit
```

作成された Unit テスト用のファイルを以下のようにしてください。

```php:tests/Unit/BookServiceTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;
use App\Models\Book;
use App\Services\BookService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Repositories\BookRepositoryInterface;

class BookServiceTest extends TestCase
{
    use RefreshDatabase;

    protected BookService $bookService;

    protected function setUp(): void
    {
        parent::setUp();
        $this->bookService = app(BookService::class); // DIコンテナから取得
    }

    public function test_getAllメソッドの確認()
    {
        Book::factory()->count(2)->create();

        $books = $this->bookService->getAll();

        $this->assertCount(2, $books);
    }

    public function test_createメソッドの確認()
    {
        $data = [
            'title' => '私の本',
            'author' => '山田太郎',
            'description' => '本の説明',
        ];

        $book = $this->bookService->create($data);

        $this->assertDatabaseHas('books', ['title' => '私の本']);
        $this->assertEquals('山田太郎', $book->author);
        $this->assertEquals('本の説明', $book->description);
    }

    public function test_findメソッドの確認（正常）()
    {
        $book = Book::factory()->create();

        $found = $this->bookService->find($book->id);

        $this->assertEquals($book->title, $found->title);
    }

    public function test_findメソッドの確認（例外：本が見つからなかった時）()
    {
        $this->expectException(\App\Exceptions\BookNotFoundException::class);
        $this->bookService->find(999); // 存在しないID
    }

    public function test_updateメソッドの確認()
    {
        $book = Book::factory()->create(['title' => '古い']);

        $this->bookService->update($book, ['title' => '新しい']);

        $this->assertDatabaseHas('books', ['title' => '新しい']);
    }

    public function test_deleteメソッドの確認()
    {
        $book = Book::factory()->create();

        $this->bookService->delete($book);

        $this->assertDatabaseMissing('books', ['id' => $book->id]);
    }

    public function test_createメソッドの確認（内部で呼んでいるRepositoryをモックにした版）()
    {
        $repo = Mockery::mock(BookRepositoryInterface::class);
        $repo->shouldReceive('create')->once()->andReturn(new Book(['title' => 'モックのタイトル']));

        $service = new BookService($repo);
        $book = $service->create(['title' => 'モックのタイトル']);

        $this->assertEquals('モックのタイトル', $book->title);
    }
}
```

## 単体（Unit）テストの実行結果

```shell
$ ./vendor/bin/sail up -d # Laravel起動
$ ./vendor/bin/sail artisan test tests/Unit # Unitフォルダ内のみ指定してテスト実行

   PASS  Tests\Unit\BookRepositoryTest
  ✓ allメソッドの確認                                                                                                        0.96s
  ✓ createメソッドの確認                                                                                                     0.02s
  ✓ findメソッドの確認                                                                                                       0.01s
  ✓ updateメソッドの確認                                                                                                     0.02s
  ✓ deleteメソッドの確認                                                                                                     0.02s

   PASS  Tests\Unit\BookServiceTest
  ✓ get allメソッドの確認                                                                                                    0.02s
  ✓ createメソッドの確認                                                                                                     0.02s
  ✓ findメソッドの確認（正常）                                                                                                   0.02s
  ✓ findメソッドの確認（例外：本が見つからなかった時）                                                                                       0.02s
  ✓ updateメソッドの確認                                                                                                     0.02s
  ✓ deleteメソッドの確認                                                                                                     0.02s
  ✓ createメソッドの確認（内部で呼んでいる repositoryをモックにした版）                                                                        0.01s

  Tests:    12 passed (17 assertions)
  Duration: 1.20s
```

# 機能（Feature）テストのコード

機能テストなので、プレゼンテーション層の **HTTPリクエスト単位** のテストを行います。

## プレゼンテーション層のテストコード

以下のコマンドで プレゼンテーション層の Feature テスト用ファイルの雛形を作成します。

```
$ php artisan make:test BookControllerTest
```

作成された Feature テスト用のファイルを以下のようにしてください。

```php:tests/Feature/BookControllerTest.php
<?php

namespace Tests\Feature;

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

class BookControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_indexアクションの確認()
    {
        Book::factory()->count(3)->create(); // 3件のダミーデータを作成

        $response = $this->get(route('books.index'));

        $response->assertStatus(200);
        $response->assertViewIs('books.index'); // View名は books.indexか確認
        $response->assertViewHas('books');  // Viewにbooksが渡されているか確認
        $this->assertCount(3, $response->viewData('books')); // Viewに渡されたbooksの数を確認
    }

    public function test_createアクションの確認()
    {
        $response = $this->get(route('books.create'));

        $response->assertStatus(200);
        $response->assertViewIs('books.create'); // View名は books.createか確認
    }

    public function test_storeアクションの確認()
    {
        $data = [
            'title' => 'PHP入門',
            'author' => '山田太郎',
            'description' => 'PHPの入門書です。',
        ];

        $response = $this->post(route('books.store'), $data);

        $response->assertRedirect(route('books.index')); // リダイレクト先の確認
        $response->assertSessionHas('success', 'Book created!'); // セッションに成功メッセージがあるか確認
        $this->assertDatabaseHas('books', $data); // データベースにデータが保存されているか確認
    }

    public function test_showアクションの確認（正常）()
    {
        $book = Book::factory()->create(); // ダミーデータを作成

        $response = $this->get(route('books.show', $book->id));

        $response->assertStatus(200);
        $response->assertViewIs('books.show'); // View名は books.showか確認
        $response->assertViewHas('book', $book); // Viewにbookが渡されているか確認
        $response->assertSee($book->title); // Viewにタイトルが表示されているか確認
        $response->assertSee($book->author); // Viewに著者名が表示されているか確認
        $response->assertSee($book->description); // Viewに説明が表示されているか確認
    }

    public function test_showアクションの確認（例外）()
    {
        // 存在しないIDを指定してリクエスト
        $response = $this->get(route('books.show', ['book' => 999]));
    
        $response->assertStatus(404);
        $response->assertSee('本がみつかりません'); // エラーメッセージが表示されているか確認
    }

    public function test_editアクションの確認()
    {
        $book = Book::factory()->create(); // ダミーデータを作成

        $response = $this->get(route('books.edit', $book->id));

        $response->assertStatus(200);
        $response->assertViewIs('books.edit'); // View名は books.editか確認
        $response->assertViewHas('book', $book); // Viewにbookが渡されているか確認
        $response->assertSee($book->title); // Viewにタイトルが表示されているか確認
        $response->assertSee($book->author); // Viewに著者名が表示されているか確認
        $response->assertSee($book->description); // Viewに説明が表示されているか確認
    }

    public function test_updateアクションの確認()
    {
        $book = Book::factory()->create(); // ダミーデータを作成

        // 更新するデータ
        $updated = [
            'title' => '更新後のタイトル',
            'author' => '更新後の著者名',
            'description' => '更新後の説明',
        ];

        $response = $this->put(route('books.update', $book->id), $updated);

        $response->assertRedirect(route('books.index')); // リダイレクト先の確認
        $response->assertSessionHas('success', 'Book updated!'); // セッションに成功メッセージがあるか確認
        $this->assertDatabaseMissing('books', $book->toArray()); // 古いデータが削除されているか確認
        $this->assertDatabaseHas('books', $updated); // 新しいデータが保存されているか確認
    }

    public function test_destroyアクションの確認()
    {
        $book = Book::factory()->create(); // ダミーデータを作成

        $response = $this->delete(route('books.destroy', $book->id));

        $response->assertRedirect(route('books.index')); // リダイレクト先の確認
        $response->assertSessionHas('success', 'Book deleted!'); // セッションに成功メッセージがあるか確認
        $this->assertDatabaseMissing('books', ['id' => $book->id]); // 古いデータが削除されているか確認
    }
}
```

## 機能（Feature）テストの実行結果

```shell
$ ./vendor/bin/sail artisan test tests/Feature

   PASS  Tests\Feature\BookControllerTest
  ✓ indexアクションの確認                                                                                                     0.79s
  ✓ createアクションの確認                                                                                                    0.02s
  ✓ storeアクションの確認                                                                                                     0.02s
  ✓ showアクションの確認（正常）                                                                                                  0.02s
  ✓ showアクションの確認（例外）                                                                                                  0.02s
  ✓ editアクションの確認                                                                                                      0.01s
  ✓ updateアクションの確認                                                                                                    0.02s
  ✓ destroyアクションの確認                                                                                                   0.02s

  Tests:    8 passed (33 assertions)
  Duration: 0.95s
```