目次
概要
本(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を以下のように変更します。
:
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 の内容を以下のように変更します。
APP_ENV=testing # testingに変更
DB_DATABASE=testing # testingに変更
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モデルはの内容は以下となります。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
protected $fillable = ['title', 'author', 'description']; // 一括代入(fillable)を設定
}
マイグレーションファイルの内容は以下となります。
<?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の内容は以下となります。
<?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
内容は以下となります。
<?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
実装の内容は以下となります。
<?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の内容は以下となります。
<?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 の内容は以下となります。
<?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 の内容は以下となります。
<?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 の内容は以下となります。
<?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 の内容は以下となります。
<?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 の内容は以下となります。
<?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用コントローラーの内容は以下となります。
<?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のルーティングを設定します。
<?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用コントローラーの内容は以下となります。
<?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のルーティングを設定します。
<?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 に教えます。
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
<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>
<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>
<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>
<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 ← 修正するファイル
:
:
'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 にアクセスして以下のような画面が出たら成功です。
新規作成のリンクをクリックすると、以下のような画面になるので、色々データを入力してみてください。
APIの場合にはPostmanなどのクライアントソフトを使って、以下のようにGETリクエストするとトップページのJSONを取得します。
新規作成の場合には、以下のようにリクエストJSONをセットしてPOST送信するとデータが登録されます。
こちらも色々データを入力してみてください。
単体(Unit)テストのコード
ユニットテストなので、ユースケース層の メソッド単位 のテストを行います。
リポジトリ層のテストコード
以下のコマンドで ユースケース層の Unit テスト用ファイルの雛形を作成します。
$ php artisan make:test BookUseCaseTest --unit
作成された Unit テスト用のファイルを以下のようにしてください。
<?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 テスト用のファイルを以下のようにしてください。
<?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 テスト用のファイルを以下のようにしてください。
<?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 テスト用のファイルを以下のようにしてください。
<?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