🐘

【Laravel】モックを使ってテスト工数を削減した話

2024/12/19に公開

はじめに

株式会社ハックツ、エンジニアのみき(@take_cantik)です。⛏️

今回はとある受託開発のテストについて、モックを使用してテスト工数を削減した話について書いていこうと思います。

前提

使用技術

  • PHP v8.3
  • Laravel v11

テストツール

  • phpunit v11

開発環境

  • Docker
    • php
    • nginx
    • db
    • db-test

構成図概要

構成図

結論

  • RepositoryのテストはモックせずにDB接続して行う
  • UseCase, ControllerのテストはRepositoryをモックして行う
    • モックにはMockeryを使用

モックとは

モックは、プログラム内の特定の依存部分をテスト用の代用品に置き換えるというものです。
一般的には、外部システムやDBなど、テスト対象が依存する部分を置き換えるために使用されます。

モックのメリットデメリットは以下の通りになります。

メリット

  • 依存関係の分離
  • テスト速度の向上
  • データや状態の制御
  • コスト削減

デメリット

  • 実際の挙動との乖離
  • メンテナンスコスト
  • 信頼性の低下
メリデメ詳細

メリット

依存関係の分離
テスト対象のコードが外部システムや依存するモジュールに影響されないので、単独の動作を確認できます。
また、外部システムなどがダウンしている状態でもテストを行うことができます。

テスト速度の向上
外部システムへのアクセスや準備(Seeder等)が不要になるため、テストの実行時間が大幅に短縮されます。

データや状態の制御
特定のシナリオや異常系を容易に再現することができる。
外部APIなどの異常系のテストをしたい場合には、失敗のレスポンスが欲しいですが、モックを使えば簡単に再現できます。

コスト削減
外部APIなどを使わずに済むため、APIの利用料金やネットワークコストなどがかかりません。

デメリット

実際の挙動との乖離
モックしたものは実際の挙動と完全に一致しない場合があるため、本番環境で予期しない挙動が起こることがあります。

メンテナンスコスト
モックしている依存モジュールや外部サービスが変更されるたびに、期待値データや実装を更新する必要があります。

信頼性の低下
モックが不正確な場合やモックの多様によって「テストは通っているのに実際には動かない」という状況が発生することがあります。

課題と背景

とある受託開発のバックエンドのテストでは、下記理由からモックをせずにテスト用のDBに繋いでテストをしていました。
(外部APIを使用している部分のみモックを使用しています。)

  • Unitテストの信頼性の向上
  • CI上のイメージで簡単にDBの構築・破棄を簡単に行うことができるため

しかし、この受託開発はサービスの特性上、マスターデータのテーブル数が多く、さらにデータ間の繋がりがとても複雑でした。
そのため、汎用的なSeederを作るとテスト毎に作成するデータ量が膨大になり、テストの実行時間が増えてしまうという懸念がありました。
なので、テスト毎にSeederを作成していましたが、その分作成するSeeder量が多いため、開発時間のうちSeeder作成や調整の時間が過半数を占めているという状況でした。


現在のサービスはv1の段階なのですが、今後v4やv5まで同等規模のバージョンアップが想定されています。
このような状況において、v2の開発に着手する段階で既にテスト工数が過大となっており、さらなる拡張に対する懸念が強まっています。

このままのテスト方針では、将来的なバージョンアップに伴いテスト工数の増加が避けられないため、改善が求められていました。

※ v1時点でのテスト数と実行時間 (685 tests, 936 assertions, 約2分)

今回の対応策

この課題を解決するために、以下のような対策を行いました。

  • DBとの接続を担当している、RepositoryのテストはモックせずにDB接続して行う。
  • Repositoryを使用している(内部依存も同様)、UseCaseやControllerのテストはRepositoryをモックして行う。

Repositoryでモックを使用しない理由は、
実際に発行されたクエリから、「期待されたデータの取得やデータの更新ができること」についての信頼性の低下が致命的すぎること。
そして、CI上でのDBの再現自体は簡単に行うことできることの2点です。

Repositoryをモックする理由としては、上記でDBから期待通りの結果が得られることや、期待通りのデータの更新を行うことができることが担保されるため、DBを直接扱わない(Repositoryを介してDB操作を行う)部分は、Repositoryの期待値を設定することによって正しく動作できることを担保できるためです。

これにより、UseCaseやControllerのテストで作成していたSeederを作成する必要がなくなり、DB接続も行わないため、テスト工数の削減とテスト時間の削減につながります。

モックを利用したテスト

今回はLaravelに標準パッケージとして入っている「Mockery」というモックツールを使用しました。

例として、ユーザー作成機能について考えてみます。

仕様:

  • 送信されたデータからユーザーを作成できること
  • 同一メールアドレスのユーザーが存在する場合にはエラーを返却できること

アプリケーションコードは以下にまとめます。

アプリケーションコード

Entity

User.php
<?php

declare(strict_types=1);

namespace App\Entity;

use ...

readonly class User
{
    /**
     * @param Uuid $id
     * @param string $name
     * @param Email $email
     */
    public function __construct(
        public Uuid $id,
        public string $name,
        public Email $email,
    ) {}

    /**
     * @param string $name
     * @param Email $email
     * @return self
     */
    public static function create(string $name, Email $email): self
    {
        return new self(
            id: Uuid::generate(),
            name: $name,
            email: $email,
        );
    }
    
    ...
}

UseCase

CreateUserUseCase.php
<?php

declare(strict_types=1);

namespace App\UseCase;

use ...

class CreateUserUseCase
{
    /**
     * @param UserRepository $userRepository
     */
    public function __construct(
        private UserRepository $userRepository,
    ) {}

    /**
     * @param CreateUserDto $dto
     * @return User
     */
    public function execute(CreateUserDto $dto): User
    {
        DB::beginTransaction();
        
        try {
            $newUser = User::create(
                name: $dto->name,
                email: new Emial($dto->email),
            );

            $sameEmailUser = $this->userRepository->findByEmail($newUser->email);
            if ($sameEmailUser) {
                throw new UserAlreadyExistsException('既に同じメールアドレスのユーザーが存在します。 (email: ' . $newUser->email->value . ')');
            }

            $this->userRepository->create($newUser);

            DB::commit();
            return $newUser;
        } catch (Throwable $exception) {
            DB::rollBack();
            throw $exception;
        }
    }
}

Repository

UserRepository.php
<?php

declare(strict_types=1);

namespace App\Repository;

use ...

class UserRepository
{
    private UserModel $userModel;

    public function construct()
    {
        $this->userModel = new UserModel();
    }

    /**
     * @param Email $email
     * @return User|null
     */
    public function findByEmail(Email $email): User|null
    {
        $builder = $this->userModel->newQuery();
        $builder->where('email', $email->value);
        $result = $builder->first();
        
        if (!$result instanceof UserModel) {
            return null;
        }
        
        return new User(
            id: new Uuid($result->id),
            name: $result->name,
            email: new Email($result->email),
        );
    }

    /**
     * @param User $user
     * @return void
     */
    public function create(User $user): void
    {
        $builder = $this->userModel->newQuery();
        $builder->create([
            'id' => $user->id->value,
            'name' => $user->name,
            'email' => $user->email->value,
        ]);
    }
}

Controller

CreateUserController.php
<?php

declare(strict_types=1);

namespace App\Http\Controller;

use ...

class CreateUserController
{
    /**
     * @param CreateUserRequest $request
     * @param CreateUserUseCase $useCase
     * @return JsonResponse
     */
    public function __invoke(
        CreateUserRequest $request,
        CreateUserUseCase $useCase,
    ): JsonResponse
    {
        try {
            $dto = new CreateUserDto(
                name: $request->name,
                email: $request->email,
            );

            $user = $useCase->execute($dto);
            
            return response()->json([
                'id' => $user->id->value,
            ], 201);
        } catch (UserAlreadyExistsException $exception) {
            return response()->json([
                'message' => $exception->getMessage(),
            ], 409);
        }
    }
}

Repositoryのテスト

Repositoryに関しては、DBにテストデータを作成し、テストを行います。

Seeder

UserRepositorySeeder
<?php

namespace Database\Seeders;

use ...

class UserRepositorySeeder extends Seeder
{
    /**
     * @return void
     */
    public function run(): void
    {
        UserModel::query()->insert([
            'id' => 'e43b4983-5624-411d-b4f2-b0a2a34abffc',
            'name' => 'テスト太郎',
            'email' => 'test001@example.com',
        ]);
    }
}

RepositoryTest

UserRepositoryTest
<?php

/** @noinspection NonAsciiCharacters */

namespace Tests\Unit\Repository;

use ...

class UserRepositoryTest extends TestCase
{
    use DatabaseTransactions;

    /**
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();
        $this->seed(UserRepositorySeeder::class);
    }

    // findByEmailメソッドのテスト

    #[Test]
    public function 特定のメールアドレスからユーザーを取得できること(): void
    {
        $expected = new User(
            id: new Uuid('e43b4983-5624-411d-b4f2-b0a2a34abffc'),
            name: 'テスト太郎',
            email: new Email('test001@example.com'),
        );
        $repository = new UserRepository();

        $actual = $repository->findByEmail($expected->email);

        $this->assertEquals($expected, $actual);
    }

    #[Test]
    public function 特定のメールアドレスのユーザーが存在しない場合はnullを返却できること(): void
    {
        $repository = new UserRepository();

        $actual = $repository->findByEmail(new Email('not.exist@example.com'));

        $this->assertNull($actual);
    }

    // createメソッドのテスト

    #[Test]
    public function ユーザーを新規作成できること(): void
    {
        $createTarget = new User(
            id: new Uuid('be85d92c-934d-4e61-97fe-3175681b46cc'),
            name: '新規花子',
            email: new Email('new.one@example.com'),
        );
        $repository = new UserRepository();

        $repository->create($createTarget);

        $this->assertDatabaseHas('user', [
            'id' => 'be85d92c-934d-4e61-97fe-3175681b46cc',
            'name' => '新規花子',
            'email' => 'new.one@example.com',
        ]);
    }
}

モックを利用したUseCaseのテスト

Mockeryを使うと、以下のように簡単にモックすることがきます。

// UserRepositoryクラスのモックを作成
$repositoryMock = Mockery::mock(UserRepository::class);

// findByEmailの返り値を任意に設定
$repositoryMock->shouldReceive('findByEmail')
    ->andReturn(new User(...));

これで作成したモックRepositoryをUseCaseにDIしてあげることで、モックを利用できます。
実際のUseCaseのテストコードはこんな感じ↓

CreateUserUseCaseTest
<?php

/** @noinspection NonAsciiCharacters */

namespace Tests\Unit\UseCase;

use ...

class CreateUserUseCaseTest extends TestCase
{
    #[Test]
    public function ユーザーを作成できること(): void
    {
        // UserRepositoryのモックを作成
        $userRepositoryMock = Mockery::mock(UserRepository::class);

        // findByEmailメソッドが呼ばれた際にnullを返却するように設定
        $userRepositoryMock->shouldReceive('findByEmail')
            ->andReturnNull();

        // createメソッドが呼ばれた際に何も返却しないように設定
        $userRepositoryMock->shouldReceive('create')
            ->andReturnUndefined();

        // UserRepositoryのモックをDIしてCreateUserUseCaseを作成
        $useCase = new CreateUserUseCase($userRepositoryMock);

        $dto = new CreateUserDto(
            name: '新規花子',
            email: 'new.one@example.com',
        );

        $actual = $useCase->execute($dto);

        $this->assertInstanceOf(User::class, $actual);
        $this->assertSame($dto->name, $actual->name);
        $this->assertSame($dto->email, $actual->email->value);
    }

    #[Test]
    public function 同一メールアドレスのユーザーが存在している場合例外をスローできること(): void
    {
        // UserRepositoryのモックを作成
        $userRepositoryMock = Mockery::mock(UserRepository::class);

        $sameEmailUser = new User(
            id: new Uuid('e43b4983-5624-411d-b4f2-b0a2a34abffc'),
            name: 'テスト太郎',
            email: new Email('test001@example.com'),
        );

        // findByEmailメソッドが呼ばれた際にユーザーを返却するように設定
        $userRepositoryMock->shouldReceive('findByEmail')
            ->andReturn($sameEmailUser);

        // createメソッドが呼ばれた際に何も返却しないように設定
        $userRepositoryMock->shouldReceive('create')
            ->andReturnUndefined();

        // UserRepositoryのモックをDIしてCreateUserUseCaseを作成
        $useCase = new CreateUserUseCase($userRepositoryMock);

        $dto = new CreateUserDto(
            name: $sameEmailUser->name,
            email: $sameEmailUser->email->value,
        );

        $this->expectException(UserAlreadyExistsException::class);
        $useCase->execute($dto);
    }

    /**
     * テストメソッド実行後にMockeryの設定を解除
     */
    public function tearDown(): void
    {
        parent::tearDown();
        Mockery::close();
    }
}

まとめ

長くなるので、Controllerのテストは気になった方は下記を見てください。

controllerのFeatureテストコード
CreateUserControllerTest
<?php

/** @noinspection NonAsciiCharacters */

namespace Tests\Feature\Controller;

use ...

class CreateUserControllerTest extends TestCase
{
    #[Test]
    public function ユーザーを新規作成できること(): void
    {
        // UserRepositoryのモックを作成
        $userRepositoryMock = Mockery::mock(UserRepository::class);

        // findByEmailメソッドが呼ばれた際にnullを返却するように設定
        $userRepositoryMock->shouldReceive('findByEmail')
            ->andReturnNull();

        // createメソッドが呼ばれた際に何も返却しないように設定
        $userRepositoryMock->shouldReceive('create')
            ->andReturnUndefined();

        // Laravelのサービスコンテナにモックのインスタンスを登録
        $this->app->instance(UserRepository::class, $userRepositoryMock);

        $response = $this->postJson('/api/users', [
            'name' => '新規花子',
            'email' => 'new.one@example.com',
        ]);

        $response->assertStatus(201);
        $response->assertJsonStructure([
            'id',
        ]);
    }

    #[Test]
    public function 同一メールアドレスのユーザーが存在している場合エラーを返却できること(): void
    {
        // UserRepositoryのモックを作成
        $userRepositoryMock = Mockery::mock(UserRepository::class);

        $sameEmailUser = new User(
            id: new Uuid('e43b4983-5624-411d-b4f2-b0a2a34abffc'),
            name: 'テスト太郎',
            email: new Email('test001@example.com'),
        );

        // findByEmailメソッドが呼ばれた際にユーザーを返却するように設定
        $userRepositoryMock->shouldReceive('findByEmail')
            ->andReturn($sameEmailUser);

        // createメソッドが呼ばれた際に何も返却しないように設定
        $userRepositoryMock->shouldReceive('create')
            ->andReturnUndefined();

        // Laravelのサービスコンテナにモックのインスタンスを登録
        $this->app->instance(UserRepository::class, $userRepositoryMock);
        
        $response = $this->postJson('/api/users', [
            'name' => $sameEmailUser->name,
            'email' => $sameEmailUser->email->value,
        ]);
        
        $response->assertStatus(409);
    }
}

Mockeryは他にも様々な機能が存在しており、1回目の呼び出し時のみこれを返して欲しいなどの設定をすることも可能です。
詳細については下記参照してください。

https://readouble.com/laravel/11.x/ja/mocking.html

さいごに

Repositoryをモックをして、テスト工数を削減した話についてまとめました。
この対応により、UseCase、Controllerのテストの工数を大幅に削減することができました。

一点、Repositoryの仕様が変わった時に、各テストでRepositoryの期待値を都度変える必要がありますが、抜け漏れた際にテストは通るけど動かないという事象が発生する可能性が高くなってしますことが懸念されます。
こちらを解決するために、Repositoryの期待値Seederのようなものを作成できればと考えています。
しかし、これを行うにもいくつかの懸念点があるので、それについてはまた別の記事で紹介できればと思います。

今回の記事について、知見のある方は、更にこうした方がいいなどありましたら、ご教授いただけると幸いです。

では、また別の記事で🦴

Hackz Inc.

Discussion