1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-06-06

目次

概要

本(Book)のCRUD処理の Web版API版 を Laravelを使って、クリーン・アーキテクチャ で書いてみました。
環境構築には、Laravel Sail を使います。

開発環境

OS

  • Windows11上のWSL2で動作するUbuntu

以下がインストールされている事

  • Composer v2以上

環境構築

# 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を以下のように変更します。

.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 を基にテスト用の環境設定ファイルを作成します。

$ cp .env .env.testing

.env.testing の内容を以下のように変更します。

.env.testing
APP_ENV=testing        # testingに変更
DB_DATABASE=testing    # testingに変更

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

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

Laravel Sail を起動します。

$ ./vendor/bin/sail up -d

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

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

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

Book プログラムの構成

クリーンアーキテクチャの各層と、ディレクトリの関係は以下です。

app/
  ├─ Model/
  │ └── Book.php .....※1
  │  
  ├─ Domain/
  │ ├── Entities/          ← (1)エンティティ層(ロジックがあれば、Unit テスト対象:今回は無し)
  │ │  └── Book.php .....※1
  │ └── Repositories/
  │    └── BookRepositoryInterface.php .....※2
  │  
  ├─ Application/
  │ └── UseCases/          ← (2)ユースケース層(Unit テスト対象)
  │    ├─ CreateBookUseCase.php
  │    ├─ UpdateBookUseCase.php
  │    ├─ DeleteBookUseCase.php
  │    ├─ GetBookListUseCase.php
  │    └─ GetBookUseCase.php
  │
  ├─ Infrastructure/        ← (4)フレームワーク&ドライバ層(Feature テスト対象)
  │ └── Repositories/
  │    └─ BookRepository.php
  │ 
  ├─ DTOs/
  │ └──  BookDto.php
  │ 
  ├─ Http/
  │  └─ Controllers/        ← (3)インターフェース アダプタ層(Feature テスト対象)
  │    ├─ WebBookController.php
  │    └─ ApiBookController.php
  │
  ├─ routes/             ← (3)インターフェース アダプタ層(Feature テスト対象)
  │    ├── web.php
  │    └── api.php     

何故 Book.php(※1)が2つ存在するのか?

Laravelで Model と Entity に同じ名前の Book.php が存在するのは、責任と関心の分離 を明確にするためです。目的がまったく異なるため、名前が同じでも役割は異なります。

■ App\Models\Book
Laravelの Eloquent ORM が提供する データベースとのやり取りのためのクラス。

  • テーブルとマッピングされている(例:books テーブル)
  • DBとのCRUD操作ができる
  • Model::find(), Model::where(), save() などが使える
  • データベースアクセスできる「永続化層」

■ App\Domain\Entities\Book
クリーンアーキテクチャでいうところの、純粋なドメインオブジェクト。

  • ビジネスルールや振る舞い(メソッド)を内包
  • LaravelのEloquentとは無関係
  • データベースやフレームワークに依存しない
  • ビジネスロジックの中核

何故「BookRepositoryInterface(※2)」は BookRepository.php と同じ Infrastructure ディレクトリではないのか?

Infrastructure ディレクトリ は Laravel や DB(Eloquent)などの 外部技術に依存している層です。
一方、BookRepositoryInterface はアプリケーションやドメインの コアロジック(=内側)で使われ、どんな DB でも動くように中立な定義でなければなりません。
そのような理由から「インターフェースは外部に依存してはいけない」= Infrastructure には置かないのです。

本体プログラムのコード

Bookモデルとマイグレーション作成

以下コマンドで Book モデルとマイグレーションを作成します。

$ php artisan make:model Book -m

Bookモデルはの内容は以下となります。

app/Models/Book.php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    protected $fillable = ['title', 'author', 'description']; // 一括代入(fillable)を設定
}

マイグレーションファイルの内容は以下となります。

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テーブルのマイグレーションを実施します。

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

DTOの作成

DTO(Data Transfer Object)の役割は、 「層(レイヤー)間でデータを運ぶ専用のオブジェクト」 です。クリーンアーキテクチャにおいて、エンティティやリクエストの生データと切り離して、シンプルかつ明確にデータのやり取りを行うことが目的です。

$ mkdir app/DTOs
$ touch app/DTOs/BookDto.php

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

app/DTOs/BookDto.php
<?php
namespace App\DTOs;

class BookDto
{
    public function __construct(
        public readonly ?int $id,
        public readonly string $title,
        public readonly string $author,
        public readonly ?string $description,
    ) {}
}

(1) のエンティティ層の作成

$ mkdir -p app/Domain/Entities
$ touch app/Domain/Entities/Book.php

内容は以下となります。

app/Domain/Entities/Book.php
<?php
namespace App\Domain\Entities;

class Book
{
    public function __construct(
        private int $id,
        private string $title,
        private string $author,
        private ?string $description,
    ) {}

    public function getId(): int { return $this->id; }
    public function getTitle(): string { return $this->title; }
    public function getAuthor(): string { return $this->author; }
    public function getDescription(): string | null { return $this->description; }
}

(4) のフレームワーク&ドライバ層(実装)と、そのインターフェース(I/F)の作成

$ mkdir -p app/Infrastructure/Repositories
$ touch app/Infrastructure/Repositories/BookRepository.php

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

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

use App\Domain\Entities\Book;
use App\Models\Book as EloquentBook;
use App\DTOs\BookDto;
use App\Domain\Repositories\BookRepositoryInterface;

class BookRepository implements BookRepositoryInterface
{
    public function getById(int $id): ?Book
    {
        $record = EloquentBook::find($id);
        if (!$record) return null;

        return new Book(
            $record->id,
            $record->title,
            $record->author,
            $record->description
        );
    }

    public function getAll(): array
    {
        return EloquentBook::all()
            ->map(fn($record) => new BookDto($record->id, $record->title, $record->author, $record->description))
            ->toArray();
    }

    public function save(BookDto $book): BookDto
    {
        $record = $book->id ? EloquentBook::find($book->id) : new EloquentBook();
        $record->title = $book->title;
        $record->author = $book->author;
        $record->description = $book->description;
        $record->save();

        return new BookDto($record->id, $record->title, $record->author, $record->description);
    }

    public function delete(int $id): void
    {
        EloquentBook::destroy($id);
    }
}

I/Fを作成します。

$ mkdir -p app/Domain/Repositories
$ touch app/Domain/Repositories/BookRepositoryInterface.php

I/Fの内容は以下となります。

app/Domain/Repositories/BookRepositoryInterface.php
<?php

namespace App\Domain\Repositories;

use App\Domain\Entities\Book;
use App\DTOs\BookDto;

interface BookRepositoryInterface
{
    public function getById(int $id): ?Book;

    public function getAll(): array;

    public function save(BookDto $book): BookDto;

    public function delete(int $id): void;
}

(2) のユースケース層の作成

$ mkdir app/Application/UseCases
$ touch app/Application/UseCases/GetBookListUseCase.php
$ touch app/Application/UseCases/GetBookUseCase.php
$ touch app/Application/UseCases/CreateBookUseCase.php
$ touch app/Application/UseCases/UpdateBookUseCase.php
$ touch app/Application/UseCases/DeleteBookUseCase.php

GetBookListUseCase.php の内容は以下となります。

app/Application/UseCases/GetBookListUseCase.php
<?php
namespace App\Application\UseCases;

use App\Domain\Repositories\BookRepositoryInterface;

class GetBookListUseCase
{
    public function __construct(
        private BookRepositoryInterface $bookRepository
    ) {}

    public function execute(): array
    {
        return $this->bookRepository->getAll();
    }
}

GetBookUseCase.php の内容は以下となります。

app/Application/UseCases/GetBookUseCase.php
<?php
namespace App\Application\UseCases;

use App\Domain\Repositories\BookRepositoryInterface;
use App\DTOs\BookDto;

class GetBookUseCase
{
    public function __construct(
        private BookRepositoryInterface $bookRepository
    ){}

    public function execute(int $id): ?BookDto
    {
        $book = $this->bookRepository->getById($id);
        if (!$book) return null;
        return new BookDto($book->getId(), $book->getTitle(), $book->getAuthor(), $book->getDescription());
    }
}

CreateBookUseCase.php の内容は以下となります。

app/Application/UseCases/CreateBookUseCase.php
<?php
namespace App\Application\UseCases;

use App\Domain\Repositories\BookRepositoryInterface;
use App\DTOs\BookDto;
use App\Domain\Entities\Book;

class CreateBookUseCase
{
    public function __construct(
        private BookRepositoryInterface $bookRepository
    ){}

    public function execute(string $title, string $author, ?string $description): BookDto
    {
        $bookDto = new BookDto(null, $title, $author, $description);
        return $this->bookRepository->save($bookDto);
    }
}

UpdateBookUseCase.php の内容は以下となります。

app/Application/UseCases/UpdateBookUseCase.php
<?php
namespace App\Application\UseCases;

use App\Domain\Repositories\BookRepositoryInterface;
use App\DTOs\BookDto;

class UpdateBookUseCase
{
    public function __construct(
        private BookRepositoryInterface $bookRepository
    ){}

    public function execute(int $id, string $title, string $author, ?string $description): ?BookDto
    {
        $book = $this->bookRepository->getById($id);
        if (!$book) return null;

        $bookDto = new BookDto($id, $title, $author, $description);
        return $this->bookRepository->save($bookDto);
    }
}

DeleteBookUseCase.php の内容は以下となります。

app/Application/UseCases/DeleteBookUseCase.php
<?php
namespace App\Application\UseCases;

use App\Domain\Repositories\BookRepositoryInterface;

class DeleteBookUseCase
{
    public function __construct(
        private BookRepositoryInterface $bookRepository
    ){}

    public function execute(int $id): void
    {
        $this->bookRepository->delete($id);
    }
}

(3) のインターフェース アダプタ層の作成

ここでは、Web用と、API用の2つを作成します。
インターフェースアダプタ層さえ変えれば、上位のエンティティ層やユースケース層は変更しなくても利用できます。

まずはWeb用のコントローラーを作成します。

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

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

app/Http/Controllers/WebBookController.php
<?php
namespace App\Http\Controllers;

use App\Application\UseCases\CreateBookUseCase;
use App\Application\UseCases\UpdateBookUseCase;
use App\Application\UseCases\DeleteBookUseCase;
use App\Application\UseCases\GetBookUseCase;
use App\Application\UseCases\GetBookListUseCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class WebBookController extends Controller
{
    private GetBookListUseCase $getListUseCase;
    private GetBookUseCase $getUseCase;
    private CreateBookUseCase $createUseCase;
    private UpdateBookUseCase $updateUseCase;
    private DeleteBookUseCase $deleteUseCase;
    
    public function __construct(
        GetBookListUseCase $getListUseCase,
        GetBookUseCase $getUseCase,
        CreateBookUseCase $createUseCase,
        UpdateBookUseCase $updateUseCase,
        DeleteBookUseCase $deleteUseCase,
    ) {
        $this->getListUseCase = $getListUseCase;
        $this->getUseCase = $getUseCase; 
        $this->createUseCase = $createUseCase;
        $this->updateUseCase = $updateUseCase;
        $this->deleteUseCase = $deleteUseCase;
    }

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

    public function show(int $id)
    {
        $book = $this->getUseCase->execute($id);
        if (!$book) {
            return response("本が見つかりません", 404);
            // return redirect()->route('books.index')->with('error', '本が見つかりません');
        }
        return view('books.show', compact('book'));
    }

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

    public function store(Request $request)
    {
        // 入力バリデーション:エラー時には自動的に元のフォームへリダイレクト
        $validated = $request->validate([
            'title' => 'required|string|min:3',
            'author' => 'required|string|min:3',
            'description' => 'nullable|string',
        ]);

        try{
            $this->createUseCase->execute($validated['title'], $validated['author'], $validated['description']);
        } catch (\InvalidArgumentException $e) {
            return redirect()->back()->withErrors(['error' => $e->getMessage()]);
        }

        return redirect()->route('books.index')->with('success', '本を作成しました');
    }

    public function edit(int $id)
    {
        $book = $this->getUseCase->execute($id);
        if (!$book) {
            return redirect()->route('books.index')->with('error', '本が見つかりません');
        }

        return view('books.edit', compact('book'));
    }

    public function update(Request $request, int $id)
    {
        $validated = $request->validate([
            'title' => 'required|string|min:3',
            'author' => 'required|string|min:3',
            'description' => 'nullable|string',
        ]);

        try{
            $book = $this->updateUseCase->execute($id, $validated['title'], $validated['author'], $validated['description']);
            if (!$book) {
                return redirect()->route('books.index')->with('error', 'Book not found');
            }
        } catch (\InvalidArgumentException $e) {
            return redirect()->back()->withErrors(['error' => $e->getMessage()]);
        }

        return redirect()->route('books.index')->with('success', '本を更新しました');
    }

    public function destroy(int $id)
    {
        $this->deleteUseCase->execute($id);
        return redirect()->route('books.index')->with('success', '本を削除しました');
    }
}

Webのルーティングを設定します。

routes/web.php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\BookController;

Route::get('/books', [WebBookController::class, 'index'])->name('books.index');
Route::get('/books/create', [WebBookController::class, 'create'])->name('books.create');
Route::get('/books/{id}/edit', [WebBookController::class, 'edit'])->name('books.edit');
Route::post('/books', [WebBookController::class, 'store'])->name('books.store');;
Route::get('/books/{id}', [WebBookController::class, 'show'])->name('books.show');
Route::put('/books/{id}', [WebBookController::class, 'update'])->name('books.update');
Route::delete('/books/{id}', [WebBookController::class, 'destroy'])->name('books.destroy');

次にAPI用のコントローラーを作成します。

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

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

app/Http/Controllers/ApiBookController.php
<?php
namespace App\Http\Controllers;

use App\Application\UseCases\CreateBookUseCase;
use App\Application\UseCases\UpdateBookUseCase;
use App\Application\UseCases\DeleteBookUseCase;
use App\Application\UseCases\GetBookUseCase;
use App\Application\UseCases\GetBookListUseCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class ApiBookController extends Controller
{
    private GetBookListUseCase $getListUseCase;
    private GetBookUseCase $getUseCase;
    private CreateBookUseCase $createUseCase;
    private UpdateBookUseCase $updateUseCase;
    private DeleteBookUseCase $deleteUseCase;
    
    public function __construct(
        GetBookListUseCase $getListUseCase,
        GetBookUseCase $getUseCase,
        CreateBookUseCase $createUseCase,
        UpdateBookUseCase $updateUseCase,
        DeleteBookUseCase $deleteUseCase,
        
        
    ) {
        $this->getListUseCase = $getListUseCase;
        $this->getUseCase = $getUseCase; 
        $this->createUseCase = $createUseCase;
        $this->updateUseCase = $updateUseCase;
        $this->deleteUseCase = $deleteUseCase;
    }

    public function index()
    {
        $books = $this->getListUseCase->execute();
        return response()->json($books);
    }

    public function show(int $id)
    {
        $book = $this->getUseCase->execute($id);
        if (!$book) return response()->json(['message' => '本が見つかりません'], 404);
        return response()->json($book);
    }

    public function store(Request $request)
    {
        try {
            // 入力バリデーション
            $validator = Validator::make($request->all(), [
                'title' => 'required|string|min:3',
                'author' => 'required|string|min:3',
                'description' => 'nullable|string',
            ]);

            if ($validator->fails()) {
                return response()->json(['errors' => $validator->errors()], 422);
            }

            $validated = $validator->validated();

            $book = $this->createUseCase->execute(
                $validated['title'],
                $validated['author'],
                $validated['description'] ?? null
            );

            return response()->json($book, 201);

        } catch (\InvalidArgumentException $e) {
            return response()->json(['error' => $e->getMessage()], 400);
        } catch (\Throwable $e) {
            return response()->json(['error' => 'Unexpected error'], 500);
        }
    }

    public function update(Request $request, int $id)
    {
        try {
            // 入力バリデーション
            $validator = Validator::make($request->all(), [
                'title' => 'required|string|min:3',
                'author' => 'required|string|min:3',
                'description' => 'nullable|string',
            ]);

            if ($validator->fails()) {
                return response()->json(['errors' => $validator->errors()], 422);
            }

            $validated = $validator->validated();

            $book = $this->updateUseCase->execute(
                $id,
                $validated['title'],
                $validated['author'],
                $validated['description'] ?? null
            );
            if (!$book) {
                return response()->json(['message' => '本が見つかりません'], 404);
            }
            return response()->json($book, 201);

        } catch (\InvalidArgumentException $e) {
            return response()->json(['error' => $e->getMessage()], 400);
        } catch (\Throwable $e) {
            return response()->json(['error' => 'Unexpected error'], 500);
        }
    }

    public function destroy(int $id)
    {
        $this->deleteUseCase->execute($id);
        return response()->json(null, 204);
    }
}

APIのルーティングを設定します。

routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\BookController;

Route::get('/books', [ApiBookController::class, 'index']);
Route::post('/books', [ApiBookController::class, 'store']);
Route::get('/books/{id}', [ApiBookController::class, 'show']);
Route::put('/books/{id}', [ApiBookController::class, 'update']);
Route::delete('/books/{id}', [ApiBookController::class, 'destroy']);

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

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

app/Providers/AppServiceProvider.php
use App\Domain\Repositories\BookRepositoryInterface; // 追加
use App\Infrastructure\Repositories\BookRepository; // 追加
    
class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(BookRepositoryInterface::class, BookRepository::class); // 追加
    }
  :

Web用 Viewファイルの作成(必要最小限)

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

$ 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
resources/views/books/index.blade.php
<h1>本の一覧</h1>

@if (session('success'))
    <div style="color:green;">
        {{ session('success') }}
    </div>
@endif
@if (session('error'))
    <div style="color:red;">
        {{ session('error') }}
    </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>
resources/views/books/create.blade.php
<h1>本の新規作成</h1>
@if ($errors->has('error'))
    <div style="color:red;">
        {{ $errors->first('error') }}
    </div>
@endif

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

    <div>
        <label>タイトル:</label><br>
        @if ($errors->has('title'))
            <div style="color:red;">
                {{ $errors->first('title') }}
            </div>
        @endif
        <input type="text" name="title" value="{{ old('title') }}">
    </div>

    <div>
        <label>著者:</label><br>
        @if ($errors->has('author'))
            <div style="color:red;">
                {{ $errors->first('author') }}
            </div>
        @endif
        <input type="text" name="author" value="{{ old('author') }}">
    </div>

    <div>
        <label>説明:</label><br>
        <textarea name="description" rows="4">{{ old('description') }}</textarea>
    </div>

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

<a href="{{ route('books.index') }}"> 一覧に戻る</a>
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>
resources/views/books/edit.blade.php
<h1>本の編集</h1>
@if ($errors->has('error'))
    <div style="color:red;">
        {{ $errors->first('error') }}
    </div>
@endif

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

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

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

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

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

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

エラーメッセージの日本語化対応

lang/en ディレクトリをコピーして、lang/ja ディレクトリを作成します。
lang/ja/validation.php ファイルを修正します。

lang/
  ├─ ja/
  │ ├─ auth.php
  │ ├─ pagination.php
  │ ├─ passwords.php
  │ └─ validation.php   ← 修正するファイル
lang/ja/validation.php
     :
     :
    'min' => [
        'array' => 'The :attribute field must have at least :min items.',
        'file' => 'The :attribute field must be at least :min kilobytes.',
        'numeric' => 'The :attribute field must be at least :min.',
        'string' => ':attributeは:min文字以上入力してください。',   日本語にする
    ],
     :
     :
    'required' => ':attributeが入力されていません。',   日本語にする
     :
     :
    /*
    |--------------------------------------------------------------------------
    | Custom Validation Attributes
    |--------------------------------------------------------------------------
    |
    | The following language lines are used to swap our attribute placeholder
    | with something more reader friendly such as "E-Mail Address" instead
    | of "email". This simply helps us make our message more expressive.
    |
    */

    'attributes' => [
        'title' => 'タイトル',    追加する
        'author' => '著者',     ← 追加する
        'description' => '説明',   追加する
    ],
     :

Webの場合、ブラウザで http://localhost/books にアクセスして以下のような画面が出たら成功です。
index.png
新規作成のリンクをクリックすると、以下のような画面になるので、色々データを入力してみてください。
create.png

APIの場合にはPostmanなどのクライアントソフトを使って、以下のようにGETリクエストするとトップページのJSONを取得します。
postman_index.png

新規作成の場合には、以下のようにリクエストJSONをセットしてPOST送信するとデータが登録されます。
こちらも色々データを入力してみてください。
postman_create.png

単体(Unit)テストのコード

ユニットテストなので、ユースケース層の メソッド単位 のテストを行います。

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

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

$ php artisan make:test BookUseCaseTest --unit

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

tests/Unit/BookUseCaseTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;
use App\Domain\Entities\Book;
use App\Application\UseCases\GetBookListUseCase;
use App\Application\UseCases\GetBookUseCase;
use App\Application\UseCases\CreateBookUseCase;
use App\Application\UseCases\UpdateBookUseCase;
use App\Application\UseCases\DeleteBookUseCase;
use App\Domain\Repositories\BookRepositoryInterface;
use App\DTOs\BookDto;
use Mockery;

class BookUseCaseTest extends TestCase
{
    public function tearDown(): void
    {
        Mockery::close(); // モックをクリーンアップ
        parent::tearDown();
    }

    public function test_Book一覧を取得()
    {
        // モックの作成
        $mockRepository = Mockery::mock(BookRepositoryInterface::class);
        
        $bookList = [
            new BookDto(1, 'タイトルA', '著者A', '説明A'),
            new BookDto(2, 'タイトルB', '著者B', '説明B'),
        ];

        $mockRepository->shouldReceive('getAll')
            ->once()
            ->andReturn($bookList);

        $useCase = new GetBookListUseCase($mockRepository);
        $result = $useCase->execute();

        $this->assertCount(2, $result);
        $this->assertEquals('タイトルA', $result[0]->title);
        $this->assertEquals('著者B', $result[1]->author);
    }

    public function test_Bookの詳細を取得()
    {
        // モックの作成
        $mockRepository = Mockery::mock(BookRepositoryInterface::class);
        
        $book = new Book(1, 'タイトルA', '著者A', '説明A');

        $mockRepository->shouldReceive('getById')
            ->once()
            ->andReturn($book);

        $useCase = new GetBookUseCase($mockRepository);
        $result = $useCase->execute(1);

        $this->assertEquals('タイトルA', $result->title);
    }

    public function test_Bookを新規登録(){
        // モックの作成
        $mockRepository = Mockery::mock(BookRepositoryInterface::class);
        
        $title = 'タイトルA';
        $author = '著者A';
        $description = '説明A';

        $expectedDto = new BookDto(1, $title, $author, $description);

        $mockRepository
            ->shouldReceive('save')
            ->once()
            ->with(Mockery::on(function ($arg) use ($title, $author, $description) {
                return $arg instanceof BookDto
                    && $arg->id === null
                    && $arg->title === $title
                    && $arg->author === $author
                    && $arg->description === $description;
            }))
            ->andReturn($expectedDto);

        $useCase = new CreateBookUseCase($mockRepository);
        $result = $useCase->execute($title, $author, $description);

        $this->assertInstanceOf(BookDto::class, $result);
        $this->assertEquals(1, $result->id);
        $this->assertEquals($title, $result->title);
        $this->assertEquals($author, $result->author);
        $this->assertEquals($description, $result->description);
    }

    public function test_Bookを更新成功()
    {
        $mockRepository = Mockery::mock(BookRepositoryInterface::class);
        
        $id = 1;
        $title = 'タイトルA';
        $author = '著者A';
        $description = '説明A';

        $book = new Book($id, $title, $author, $description);

        $mockRepository
            ->shouldReceive('getById')
            ->with($id)
            ->andReturn($book);

        $expectedDto = new BookDto($id, $title, $author, $description);

        $mockRepository
            ->shouldReceive('save')
            ->with(Mockery::on(function ($arg) use ($expectedDto) {
                return $arg instanceof BookDto &&
                       $arg->id === $expectedDto->id &&
                       $arg->title === $expectedDto->title &&
                       $arg->author === $expectedDto->author &&
                       $arg->description === $expectedDto->description;
            }))
            ->andReturn($expectedDto);

        $useCase = new UpdateBookUseCase($mockRepository);
        $result = $useCase->execute($id, $title, $author, $description);

        $this->assertInstanceOf(BookDto::class, $result);
        $this->assertEquals($title, $result->title);
        $this->assertEquals($author, $result->author);
        $this->assertEquals($description, $result->description);
    }

    public function test_Bookを更新失敗()
    {
        $mockRepository = Mockery::mock(BookRepositoryInterface::class);
        
        $id = 999;
        $title = 'タイトルA';
        $author = '著者A';
        $description = '説明A';

        $mockRepository
            ->shouldReceive('getById')
            ->with($id)
            ->andReturn(null);

        $useCase = new UpdateBookUseCase($mockRepository);
        $result = $useCase->execute($id, $title, $author, $description);

        $this->assertNull($result);
    }

    public function test_Bookを削除()
    {
        $mockRepository = Mockery::mock(BookRepositoryInterface::class);
        
        $id = 1;

        $mockRepository
            ->shouldReceive('delete')
            ->once()
            ->with($id);

        $useCase = new DeleteBookUseCase($mockRepository);
        $useCase->execute($id);

        // executeは何も返さないので、明示的に「1件アサーションを行った」と通知
        $this->addToAssertionCount(1);
    }
}

単体(Unit)テストの実行結果

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

   PASS  Tests\Unit\BookUseCaseTest
  ✓ book一覧を取得                                                                                        0.09s
  ✓ bookの詳細を取得                                                                                       0.01s
  ✓ bookを新規登録                                                                                        0.01s
  ✓ bookを更新(成功)                                                                                      0.01s
  ✓ bookを更新(失敗)                                                                                      0.01s
  ✓ bookを削除                                                                                          0.01s

  Tests:    6 passed (15 assertions)
  Duration: 0.24s

機能(Feature)テストのコード

機能テストなので、インターフェース アダプタ層の HTTPリクエスト単位 と、フレームワーク&ドライバ層の DBアクセス のテストを行います。

インターフェース アダプタ層のテストコード(Web用)

以下のコマンドで インターフェース アダプタ層(Web用)の Feature テスト用ファイルの雛形を作成します。

$ php artisan make:test WebBookControllerTest

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

tests/Feature/WebBookControllerTest.php
<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Application\UseCases\GetBookListUseCase;
use App\Application\UseCases\GetBookUseCase;
use App\Application\UseCases\CreateBookUseCase;
use App\Application\UseCases\UpdateBookUseCase;
use App\Application\UseCases\DeleteBookUseCase;
use App\DTOs\BookDto;
use Mockery;

class WebBookControllerTest extends TestCase
{
    public function test_Indexアクション(): void
    {
        // モックとフェイクデータ
        $bookDtoA = new BookDto(1, 'タイトルA', '著者A', '説明A');
        $bookDtoB = new BookDto(2, 'タイトルB', '著者B', '説明B');
        $books = [$bookDtoA, $bookDtoB];

        $mockUseCase = Mockery::mock(GetBookListUseCase::class);
        $mockUseCase->shouldReceive('execute')->once()->andReturn($books);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookListUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookListUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get('/books');

        $response->assertStatus(200);
        $response->assertViewIs('books.index');
        $response->assertViewHas('books', $books);
    }

    public function test_Showアクション成功(): void
    {
        // モックとフェイクデータ
        $id = 1;
        $bookDto = new BookDto($id, 'タイトルA', '著者A', '説明A');

        $mockUseCase = Mockery::mock(GetBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->with($id)
            ->once()
            ->andReturn($bookDto);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get("/books/{$id}");

        $response->assertStatus(200);
        $response->assertViewIs('books.show');
        $response->assertViewHas('book', function ($viewBook) use ($bookDto) {
            return $viewBook === $bookDto;
        });
    }

    public function test_Showアクション該当データなし(){
        // モックとフェイクデータ
        $id = 999;

        $mockUseCase = Mockery::mock(GetBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->with($id)
            ->once()
            ->andReturn(null);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get("/books/{$id}");

        // $response->assertRedirect(route('books.index'));
        // $response->assertSessionHas('error', '本が見つかりません');
        $response->assertStatus(404);
        $response->assertSeeText('本が見つかりません');
    }

    public function test_Createアクション(){
        $response = $this->get('/books/create');

        $response->assertStatus(200);
        $response->assertViewIs('books.create');
    }

    public function test_Storeアクション成功(){
        // モックとフェイクデータ
        $title = 'タイトルA';
        $author = '著者A';
        $description = '説明A';

        $mockDto = new BookDto(1, $title, $author, $description);

        // ユースケースのモックを注入
        $mockUseCase = \Mockery::mock(CreateBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($title, $author, $description)
            ->andReturn($mockDto);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する CreateBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(CreateBookUseCase::class, $mockUseCase);

        // POSTリクエスト
        $response = $this->post('/books', [
            'title' => $title,
            'author' => $author,
            'description' => $description,
        ]);

        $response->assertRedirect('/books');
        $response->assertSessionHas('success', '本を作成しました');
    }

    public function test_Storeアクションバリデーションエラー必須チェック()
    {
        // POSTリクエスト
        $response = $this->post('/books', [
            'title' => '',
            'author' => '',
            'description' => '説明A',
        ]);

        $response->assertRedirect();

        $errors = session('errors');
        $this->assertEquals('タイトルが入力されていません。', $errors->first('title'));
        $this->assertEquals('著者が入力されていません。', $errors->first('author'));
    }

    public function test_Storeアクションバリデーションエラー文字数チェック()
    {
        // POSTリクエスト
        $response = $this->post('/books', [
            'title' => 'A',
            'author' => 'A',
            'description' => '説明A',
        ]);

        $response->assertRedirect();

        $errors = session('errors');
        $this->assertEquals('タイトルは3文字以上入力してください。', $errors->first('title'));
        $this->assertEquals('著者は3文字以上入力してください。', $errors->first('author'));
    }

    public function test_Editアクション成功(){
        // モックとフェイクデータ
        $bookId = 1;
        $bookDto = new BookDto($bookId, 'タイトルA', '著者A', '説明A');

        $mockUseCase = \Mockery::mock(GetBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId)
            ->andReturn($bookDto);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get("/books/{$bookId}/edit");

        $response->assertStatus(200);
        $response->assertViewIs('books.edit');
        $response->assertViewHas('book', $bookDto);
    }

    public function test_Editアクション該当データなし(){
        // モックとフェイクデータ
        $bookId = 999;

        $mockUseCase = \Mockery::mock(GetBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId)
            ->andReturn(null);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get("/books/{$bookId}/edit");

        $response->assertRedirect('/books');
        $response->assertSessionHas('error', '本が見つかりません');
    }

    public function test_Updateアクション成功(){ 
        // モックとフェイクデータ
        $bookId = 1;
        $data = [
            'title' => 'タイトルA',
            'author' => '著者A',
            'description' => '説明A',
        ];
        $bookDto = new BookDto($bookId, $data['title'], $data['author'], $data['description']);

        $mockUseCase = \Mockery::mock(UpdateBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId, $data['title'], $data['author'], $data['description'])
            ->andReturn($bookDto);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(UpdateBookUseCase::class, $mockUseCase);

        // PUTリクエスト
        $response = $this->put("/books/{$bookId}", $data);

        $response->assertRedirect('/books');
        $response->assertSessionHas('success',  '本を更新しました');
    }

    public function test_Updateアクションバリデーションエラー必須チェック()
    {
        // PUTリクエスト
        $bookId = 1;
        $response = $this->put("/books/{$bookId}", [
            'title' => '',
            'author' => '',
            'description' => '説明A',
        ]);

        $response->assertRedirect();

        $errors = session('errors');
        $this->assertEquals('タイトルが入力されていません。', $errors->first('title'));
        $this->assertEquals('著者が入力されていません。', $errors->first('author'));
    }

    public function test_Updateアクションバリデーションエラー文字数チェック()
    {
        // PUTリクエスト
        $bookId = 1;
        $response = $this->put("/books/{$bookId}", [
            'title' => 'A',
            'author' => 'A',
            'description' => '説明A',
        ]);

        $response->assertRedirect();

        $errors = session('errors');
        $this->assertEquals('タイトルは3文字以上入力してください。', $errors->first('title'));
        $this->assertEquals('著者は3文字以上入力してください。', $errors->first('author'));
    }

    public function test_Updateアクション該当データなし(){    
        // モックとフェイクデータ
        $bookId = 999;
        $data = [
            'title' => 'タイトルA',
            'author' => '著者A',
            'description' => '説明A',
        ];

        $mockUseCase = \Mockery::mock(UpdateBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId, $data['title'], $data['author'], $data['description'])
            ->andReturn(null);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(UpdateBookUseCase::class, $mockUseCase);

        // PUTリクエスト
        $response = $this->put("/books/{$bookId}", $data);

        $response->assertRedirect('/books');
        $response->assertSessionHas('error', 'Book not found');
    }
    
    public function test_Destroyアクション(){
        // モックとフェイクデータ
        $bookId = 1;

        $mockUseCase = \Mockery::mock(DeleteBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId)
            ->andReturnNull();

        $this->app->instance(DeleteBookUseCase::class, $mockUseCase);

        // DELETEリクエスト
        $response = $this->delete("/books/{$bookId}");

        $response->assertRedirect('/books');
        $response->assertSessionHas('success', '本を削除しました');
    }
}

インターフェース アダプタ層のテストコード(API用)

以下のコマンドで インターフェース アダプタ層(API用)の Feature テスト用ファイルの雛形を作成します。

$ php artisan make:test ApiBookControllerTest

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

tests/Feature/ApiBookControllerTest.php
<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Application\UseCases\GetBookListUseCase;
use App\Application\UseCases\GetBookUseCase;
use App\Application\UseCases\CreateBookUseCase;
use App\Application\UseCases\UpdateBookUseCase;
use App\Application\UseCases\DeleteBookUseCase;
use App\DTOs\BookDto;
use Mockery;

class ApiBookControllerTest extends TestCase
{
    public function test_Indexアクション(): void
    {
        // モックとフェイクデータ
        $bookDtoA = new BookDto(1, 'タイトルA', '著者A', '説明A');
        $bookDtoB = new BookDto(2, 'タイトルB', '著者B', '説明B');
        $books = [$bookDtoA, $bookDtoB];

        $expected = [
            [
                'id' => 1,
                'title' => 'タイトルA',
                'author' => '著者A',
                'description' => '説明A',
            ],
            [
                'id' => 2,
                'title' => 'タイトルB',
                'author' => '著者B',
                'description' => '説明B',
            ],
        ];

        $mockUseCase = Mockery::mock(GetBookListUseCase::class);
        $mockUseCase->shouldReceive('execute')->once()->andReturn($books);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookListUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookListUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get('/api/books');

        $response->assertStatus(200);
        $response->assertJson($expected);
    }

    public function test_Showアクション成功(): void
    {
        // モックとフェイクデータ
        $id = 1;
        $bookDto = new BookDto($id, 'タイトルA', '著者A', '説明A');

        $mockUseCase = Mockery::mock(GetBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->with($id)
            ->once()
            ->andReturn($bookDto);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get("/api/books/{$id}");

        $response->assertStatus(200);
        $response->assertJson([
            'id' => 1,
            'title' => 'タイトルA',
            'author' => '著者A',
            'description' => '説明A',
        ]);
    }

    public function test_Showアクション該当データなし(){
        // モックとフェイクデータ
        $id = 999;

        $mockUseCase = Mockery::mock(GetBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->with($id)
            ->once()
            ->andReturn(null);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(GetBookUseCase::class, $mockUseCase);

        // GETリクエスト
        $response = $this->get("/api/books/{$id}");

        $response->assertStatus(404);
        $response->assertJson([
            'message' => '本が見つかりません'
        ]);
    }

    public function test_Storeアクション成功(){
        // モックとフェイクデータ
        $title = 'タイトルA';
        $author = '著者A';
        $description = '説明A';

        $mockDto = new BookDto(1, $title, $author, $description);

        // ユースケースのモックを注入
        $mockUseCase = \Mockery::mock(CreateBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($title, $author, $description)
            ->andReturn($mockDto);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する CreateBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(CreateBookUseCase::class, $mockUseCase);

        // POSTリクエスト
        $response = $this->post('/api/books', [
            'title' => $title,
            'author' => $author,
            'description' => $description,
        ]);

        $response->assertStatus(201);
        $response->assertJson([
            'id' => 1,
            'title' => $title,
            'author' => $author,
            'description' => $description,
        ]);
    }

    public function test_Storeアクションバリデーションエラー必須チェック()
    {
        // POSTリクエスト
        $response = $this->post('/api/books', [
            'title' => '',
            'author' => '',
            'description' => '説明A',
        ]);

        $response->assertStatus(422);
        $response->assertJson([
            'errors' => [
                'title' => ['タイトルが入力されていません。'],
                'author' => ['著者が入力されていません。'],
            ]
        ]);
    }

    public function test_Storeアクションバリデーションエラー文字数チェック()
    {
        // POSTリクエスト
        $response = $this->post('/api/books', [
            'title' => 'A',
            'author' => 'A',
            'description' => '説明A',
        ]);

        $response->assertStatus(422);
        $response->assertJson([
            'errors' => [
                'title' => ['タイトルは3文字以上入力してください。'],
                'author' => ['著者は3文字以上入力してください。'],
            ]
        ]);
    }

    public function test_Updateアクション成功(){ 
        // モックとフェイクデータ
        $bookId = 1;
        $data = [
            'title' => 'タイトルA',
            'author' => '著者A',
            'description' => '説明A',
        ];
        $bookDto = new BookDto($bookId, $data['title'], $data['author'], $data['description']);

        $mockUseCase = \Mockery::mock(UpdateBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId, $data['title'], $data['author'], $data['description'])
            ->andReturn($bookDto);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(UpdateBookUseCase::class, $mockUseCase);

        // PUTリクエスト
        $response = $this->put("/api/books/{$bookId}", $data);

        $response->assertStatus(201);
        $response->assertJson([
            'id' => $bookId,
            'title' => $data['title'],
            'author' => $data['author'],
            'description' => $data['description'],
        ]);
    }

    public function test_Updateアクションバリデーションエラー必須チェック()
    {
        // PUTリクエスト
        $bookId = 1;
        $response = $this->put("/api/books/{$bookId}", [
            'title' => '',
            'author' => '',
            'description' => '説明A',
        ]);

        $response->assertStatus(422);
        $response->assertJson([
            'errors' => [
                'title' => ['タイトルが入力されていません。'],
                'author' => ['著者が入力されていません。'],
            ]
        ]);
    }

    public function test_Updateアクションバリデーションエラー文字数チェック()
    {
        // PUTリクエスト
        $bookId = 1;
        $response = $this->put("/api/books/{$bookId}", [
            'title' => 'A',
            'author' => 'A',
            'description' => '説明A',
        ]);

        $response->assertStatus(422);
        $response->assertJson([
            'errors' => [
                'title' => ['タイトルは3文字以上入力してください。'],
                'author' => ['著者は3文字以上入力してください。'],
            ]
        ]);
    }

    public function test_Updateアクション該当データなし(){    
        // モックとフェイクデータ
        $bookId = 999;
        $data = [
            'title' => 'タイトルA',
            'author' => '著者A',
            'description' => '説明A',
        ];

        $mockUseCase = \Mockery::mock(UpdateBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId, $data['title'], $data['author'], $data['description'])
            ->andReturn(null);

        // Laravelのサービスコンテナにモックを登録(コントローラーが依存する GetBookUseCase を実物ではなく「mockUseCase」にすり替える)
        $this->app->instance(UpdateBookUseCase::class, $mockUseCase);

        // PUTリクエスト
        $response = $this->put("/api/books/{$bookId}", $data);

        $response->assertStatus(404);
        $response->assertJson([
            'message' => '本が見つかりません',
        ]);
    }

    public function test_Destroyアクション(){
        // モックとフェイクデータ
        $bookId = 1;

        $mockUseCase = \Mockery::mock(DeleteBookUseCase::class);
        $mockUseCase->shouldReceive('execute')
            ->once()
            ->with($bookId)
            ->andReturnNull();

        $this->app->instance(DeleteBookUseCase::class, $mockUseCase);

        // DELETEリクエスト
        $response = $this->delete("/api/books/{$bookId}");

        $response->assertStatus(204);
    }
}

フレームワーク&ドライバ層のテストコード

以下のコマンドでフレームワーク&ドライバ層の Feature テスト用ファイルの雛形を作成します。

$ php artisan make:test BookRepositoryTest

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

tests/Feature/BookRepositoryTest.php
<?php

namespace Tests\Feature\Repositories;

use App\DTOs\BookDto;
use App\Domain\Entities\Book;
use App\Models\Book as EloquentBook;
use App\Infrastructure\Repositories\BookRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class BookRepositoryTest extends TestCase
{
    use RefreshDatabase;

    private BookRepository $repository;

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

    public function test_DBアクセスsave新規登録)&getByIdメソッド(): void
    {
        $dto = new BookDto(null, 'タイトル', '著者', '説明');
        $savedDto = $this->repository->save($dto);

        $this->assertNotNull($savedDto->id);
        $this->assertEquals('タイトル', $savedDto->title);

        $book = $this->repository->getById($savedDto->id);

        $this->assertNotNull($book);
        $this->assertEquals($savedDto->id, $book->getId());
        $this->assertEquals('タイトル', $book->getTitle());
    }

    public function test_DBアクセスsave更新メソッド(): void
    {
        $eloquent = EloquentBook::create([
            'title' => 'タイトル(旧)',
            'author' => '著者(旧)',
            'description' => '説明(旧)',
        ]);

        $repository = new BookRepository();

        $bookDto = new BookDto (
            $eloquent->id,
            'タイトル(新)',
            '著者(新)',
            '説明(新)'
        );

        $updatedDto = $repository->save($bookDto);

        $this->assertEquals('タイトル(新)', $updatedDto->title);
        $this->assertDatabaseHas('books', [
            'id' => $eloquent->id,
            'title' => 'タイトル(新)',
        ]);
    }

    public function test_DBアクセスdeleteメソッド()
    {
        $eloquent = EloquentBook::create([
            'title' => 'タイトル',
            'author' => '著者',
            'description' => '説明',
        ]);

        $repository = new BookRepository();
        $repository->delete($eloquent->id);

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

    public function test_DBアクセスgetAllメソッド()
    {
        EloquentBook::create([
            'title' => 'タイトルA',
            'author' => '著者A',
            'description' => '説明A',
        ]);
        EloquentBook::create([
            'title' => 'タイトルB',
            'author' => '著者B',
            'description' => '説明B',
        ]);

        $repository = new BookRepository();
        $books = $repository->getAll();

        $this->assertCount(2, $books);
        $this->assertEquals('タイトルA', $books[0]->title);
        $this->assertEquals('タイトルB', $books[1]->title);
    }
}

機能(Feature)テストの実行結果

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

   PASS  Tests\Feature\ApiBookControllerTest
  ✓ indexアクション                                                                                       0.08s
  ✓ showアクション(成功)                                                                                    0.01s
  ✓ showアクション(該当データなし)                                                                               0.01s
  ✓ storeアクション(成功)                                                                                   0.02s
  ✓ storeアクション(バリデーションエラー:必須チェック)                                                                    0.01s
  ✓ storeアクション(バリデーションエラー:文字数チェック)                                                                   0.01s
  ✓ updateアクション(成功)                                                                                  0.01s
  ✓ updateアクション(バリデーションエラー:必須チェック)                                                                   0.01s
  ✓ updateアクション(バリデーションエラー:文字数チェック)                                                                  0.01s
  ✓ updateアクション(該当データなし)                                                                             0.01s
  ✓ destroyアクション                                                                                     0.01s

   PASS  Tests\Feature\Repositories\BookRepositoryTest
  ✓ d bアクセス:save(新規登録)&get by idメソッド                                                                 0.88s
  ✓ d bアクセス:save(更新)メソッド                                                                             0.02s
  ✓ d bアクセス:deleteメソッド                                                                               0.01s
  ✓ d bアクセス:get allメソッド                                                                              0.01s

   PASS  Tests\Feature\WebBookControllerTest
  ✓ indexアクション                                                                                       0.02s
  ✓ showアクション(成功)                                                                                    0.01s
  ✓ showアクション(該当データなし)                                                                               0.01s
  ✓ createアクション                                                                                      0.01s
  ✓ storeアクション(成功)                                                                                   0.01s
  ✓ storeアクション(バリデーションエラー:必須チェック)                                                                    0.01s
  ✓ storeアクション(バリデーションエラー:文字数チェック)                                                                   0.01s
  ✓ editアクション(成功)                                                                                    0.01s
  ✓ editアクション(該当データなし)                                                                               0.01s
  ✓ updateアクション(成功)                                                                                  0.01s
  ✓ updateアクション(バリデーションエラー:必須チェック)                                                                   0.01s
  ✓ updateアクション(バリデーションエラー:文字数チェック)                                                                  0.01s
  ✓ updateアクション(該当データなし)                                                                             0.01s
  ✓ destroyアクション                                                                                     0.01s

  Tests:    29 passed (88 assertions)
  Duration: 1.29s
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?