【Laravel】Unit Testでモデル・サービスクラスをテストする

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回はFeature Testで、コントローラを含めた機能全体の動作を検証する方法を解説しました。

今回は、モデルやサービスクラスといった「単体のロジック」を検証するUnit Testを解説します。

Unit Testの役割を再確認する

Feature Testとの役割分担

前回のおさらいですが、Laravelのテストは役割によって使い分けます。

  • Feature Test:HTTPリクエストを送り、画面・APIとしての振る舞いを検証する
  • Unit Test:クラス単体のロジックが正しいかを、外部に依存せず検証する

たとえば「割引金額の計算ロジックが正しいか」を確かめたいだけなら、わざわざHTTPリクエストを送るFeature Testを書く必要はありません。

計算ロジックを持つクラスを直接呼び出して検証する方が、テストの実行も速く、失敗した原因も特定しやすくなります。

Unit Testが向いているコード

Unit Testが特に効果を発揮するのは、次のようなコードです。

  • 金額計算・割引計算などのビジネスロジック
  • 文字列の整形・バリデーションルールの判定
  • 外部APIを直接呼ばない、純粋な計算処理

逆に、DBへの保存やHTTPリクエストが絡む処理は、次回解説するFactoryなどを使ったテストか、Feature Testの領域になります。

サービスクラスをテストしてみる

テスト対象のサービスクラスを用意する

例として、注文金額に応じて割引額を計算するサービスクラスを用意します。

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

class DiscountService
{
    public function calculate(int $totalPrice): int
    {
        if ($totalPrice >= 10000) {
            return (int) ($totalPrice * 0.1);
        }

        if ($totalPrice >= 5000) {
            return (int) ($totalPrice * 0.05);
        }

        return 0;
    }
}

1万円以上で10%引き、5千円以上で5%引き、それ未満は割引なし、というシンプルなロジックです。

Unit Testを書く

このクラスをUnit Testで検証します。

<?php
// tests/Unit/DiscountServiceTest.php
namespace Tests\Unit;

use App\Services\DiscountService;
use PHPUnit\Framework\TestCase;

class DiscountServiceTest extends TestCase
{
    private DiscountService $service;

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

    public function test_1万円以上は10パーセント引きになる(): void
    {
        $this->assertEquals(1000, $this->service->calculate(10000));
    }

    public function test_5千円以上1万円未満は5パーセント引きになる(): void
    {
        $this->assertEquals(250, $this->service->calculate(5000));
    }

    public function test_5千円未満は割引なし(): void
    {
        $this->assertEquals(0, $this->service->calculate(4999));
    }
}

setUpメソッドは、各テストの実行前に必ず呼ばれる特別なメソッドです。

テストごとに毎回new DiscountService()と書く代わりに、setUpで共通の準備をまとめておくと、テストコード自体もすっきりします。

モデルのロジックをテストする

アクセサ・ミューテータのテスト

Eloquentモデルに定義した独自ロジック、たとえばアクセサもUnit Testの対象になります。

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

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;

class User extends Model
{
    protected function displayName(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->last_name . ' ' . $this->first_name,
        );
    }
}
<?php
// tests/Unit/UserTest.php
namespace Tests\Unit;

use App\Models\User;
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function test_表示名が姓名の順で結合される(): void
    {
        $user = new User([
            'last_name' => '山田',
            'first_name' => '太郎',
        ]);

        $this->assertEquals('山田 太郎', $user->display_name);
    }
}

このテストはDBへの保存を行わず、インスタンスを生成しているだけなので、Unit Testとして高速に実行できます。

モックを使って依存を切り離す

外部サービスに依存するクラスの扱い

サービスクラスの中には、外部APIを呼び出すクラスに依存しているものもあります。

そのままテストすると、実際に外部APIへリクエストが飛んでしまい、テストが不安定になったり時間がかかったりします。

そういった場合は、依存クラスをモック(偽物のオブジェクト)に差し替えてテストします。

<?php
// tests/Unit/NotificationServiceTest.php
namespace Tests\Unit;

use App\Services\NotificationService;
use App\Services\MailClient;
use PHPUnit\Framework\TestCase;

class NotificationServiceTest extends TestCase
{
    public function test_通知メールの送信メソッドが呼ばれる(): void
    {
        $mailClientMock = $this->createMock(MailClient::class);
        $mailClientMock->expects($this->once())
            ->method('send')
            ->with('user@example.com', '注文が確定しました');

        $service = new NotificationService($mailClientMock);
        $service->notifyOrderConfirmed('user@example.com');
    }
}

createMockで作った偽物のMailClientを注入し、「sendメソッドが1回だけ、指定した引数で呼ばれたか」だけを検証しています。

実際にメールが送信されることはないため、高速かつ安定してテストできます。

つまずきやすいポイント:Unit TestでDBアクセスしてしまう

テストが遅く不安定になる原因

私が最初にUnit Testを書いていた頃、モデルの検証のつもりで、うっかりDBへの保存を伴うコードを混ぜてしまったことがあります。

❌ Before:Unit TestなのにDBへ保存してしまう

<?php
namespace Tests\Unit;

use App\Models\User;
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function test_表示名が姓名の順で結合される(): void
    {
        $user = User::create([
            'last_name' => '山田',
            'first_name' => '太郎',
            'email' => 'yamada@example.com',
            'password' => bcrypt('password'),
        ]);

        $this->assertEquals('山田 太郎', $user->display_name);
    }
}

User::createはDBへの書き込みを伴うため、TestCaseのままではDB接続エラーになったり、テストが極端に遅くなったりします。

✅ After:DBが必要ない検証は、インスタンス生成だけで済ませる

<?php
public function test_表示名が姓名の順で結合される(): void
{
    $user = new User([
        'last_name' => '山田',
        'first_name' => '太郎',
    ]);

    $this->assertEquals('山田 太郎', $user->display_name);
}

「本当にDBへの保存が必要な検証か」を都度考える習慣がつくと、Unit TestとFeature Testの使い分けが自然と身につきます。

DBを使った検証がどうしても必要な場合は、次回解説するFactoryとRefreshDatabaseの出番です。

まとめ

この記事のポイント

  • Unit Testは、DBやHTTPリクエストに依存しない単体ロジックを高速に検証するテスト
  • setUpメソッドで、各テストに共通する準備処理をまとめられる
  • 外部サービスに依存するクラスは、モックに差し替えて依存を切り離す
  • Unit TestでうっかりDBアクセスするコードを書くと、テストが遅く不安定になるので注意する

次に読むべき記事

次回は、DBを使ったテストを安全かつ効率的に行うための「Factory」と「RefreshDatabase」を解説します。

→ 次の記事:FactoryとRefreshDatabaseでテストデータを整える

タイトルとURLをコピーしました