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-05-02

目次

概要

本(Book)のCRUD処理を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/
├── Http/
│   └── Controllers/
│       └── BookController.php       ← (1) プレゼンテーション層
├── Services/
│   └── BookService.php              ← (2) アプリケーション層
├── Repositories/
│   ├── BookRepositoryInterface.php  ← (3) リポジトリ層(インターフェース)
│   └── BookRepository.php           ← (4) リポジトリ層(実装)
├── Models/
│   └── Book.php                     ← (5) ドメイン層(Eloquentモデル)※php artisanコマンドで作成

本体プログラムのコード

カスタム例外処理の作成

まず先に例外処理を作ります。

$ php artisan make:exception BookNotFoundException
app/Exceptions/BookNotFoundException.php
<?php

namespace App\Exceptions;

use Exception;

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

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

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

$ php artisan make:model Book -m

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

app/Models/Book.php
<?php

namespace App\Models;

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

class Book extends Model
{
    use HasFactory; // Laravel 8以降では、Book モデルでファクトリを利用するためには、HasFactory トレイトを使用する必要があります
    protected $fillable = ['title', 'author', 'description']; // 一括代入(fillable)を設定
}

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

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 # テスト用

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

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

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

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

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

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

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

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

touch app/Repositories/BookRepository.php # 実装

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$ 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

<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>
        </li>
    @endforeach
</ul>
resources/views/books/create.blade.php
<h1>本の新規作成</h1>

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

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

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

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

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

<a href="{{ route('books.index') }}"> 一覧に戻る</a>
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>

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

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

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

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

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

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

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

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

ファクトリの作成

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

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

database/factories/BookFactory.php
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

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

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

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

$ php artisan make:test BookRepositoryTest --unit

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

tests/Unit/BookRepositoryTest.php
<?php

namespace Tests\Unit;

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

class BookRepositoryTest extends TestCase
{
    use RefreshDatabase;

    protected BookRepositoryInterface $bookRepo;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$ php artisan make:test BookServiceTest --unit

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

tests/Unit/BookServiceTest.php
<?php

namespace Tests\Unit;

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

class BookServiceTest extends TestCase
{
    use RefreshDatabase;

    protected BookService $bookService;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$ php artisan make:test BookControllerTest

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

tests/Feature/BookControllerTest.php
<?php

namespace Tests\Feature;

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

class BookControllerTest extends TestCase
{
    use RefreshDatabase;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  Tests:    8 passed (33 assertions)
  Duration: 0.95s
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?