📝

DIと単体テストと私: 緩やかな依存関係がもたらすメリット

2023/12/25に公開

はじめに

この記事は、アルサーガパートナーズ アドベントカレンダー2023、番外編の記事です。
「25日間のリレー」を成功に導いた素敵な記事たちがカレンダーに集まっていますので、よろしければ下記のリンクからご覧ください!

https://qiita.com/advent-calendar/2023/arsaga

この記事について

実務における最初の壁: DI

未経験からエンジニアとして実務に携わるようになると、誰しも「独学でやっていた時とは違うな」と感じることがたくさんあると思います。

私のサーバーサイドエンジニアとしてのキャリアはLaravelによる開発からスタートしたのですが、そんな私にとっての最初の「自己学習と実務の違い」の1つは、DI(Dependency Injection, 依存性注入) の概念が実装に利用されていること、でした。

すでに実装されている先輩方のコードを読めば「どう書けばいいか」はある程度すぐに把握できたものの、概念の理解を進めようとしても抽象的で難解な部分はどこまでも残りますし、何よりしばらくの間は実益がわからないまま、ただ「従うべきルール」としてそれに乗っかるだけの実装を続けていました。

単体テストの実装を通してDIのメリットに触れる

ということで、今回の記事では、「この記事を読んだかつての私」がその「実益」に焦点を当てて理解を進められるよう、最初にDIの(ポジティブな意味での)破壊力を感じることになった モック化の概念を利用した単体テストの実装 の手順に関する内容を通して、そのメリットについて自分なりにまとめていこうと思います。

順番は随時前後しますが、大枠の内容は下記の通りです。

  1. そもそも単体テストで目指すこと
  2. モックを利用したテストの実装手順
  3. DIのメリット(の一部)

モックを利用したテストの実装手順

前提

テスト対象クラス・関数

Laravelで下記のようなServiceクラスの実装をしているとし、このクラスの関数、すなわち hasPrivateSchedule の単体テストを行いたい、という状況を考えてみましょう。

ChristmasService.php
<?php
declare(strict_types=1);

namespace App\Services;

use App\Repositories\ScheduleRepositoryInterface;

class ChristmasService
{
    private ScheduleRepositoryInterface $scheduleRepository;

    public function __construct(
        ScheduleRepositoryInterface $scheduleRepository
    ) {
        $this->scheduleRepository = $scheduleRepository;
    }

    public function hasPrivateSchedule(string $userId): bool
    {
        $schedule = $this->scheduleRepository->findScheduleByUserId($userId, 'private');

        return ($schedule) ? true : false;
    }
}

テスト対象クラスで利用されている他クラス

上記の ChristmasService では、PHPのinterfaceである ScheduleRepositoryInterface を利用していて、その具象としての実装は ScheduleRepositoryImpl で行われているとします。

ScheduleRepositoryInterface.php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Models\Schedule;

interface ScheduleRepositoryInterface
{
    public function findScheduleByUserId(string $userId, string $type): ?Schedule;
}
ScheduleRepositoryImpl.php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Models\Schedule;

class ScheduleRepositoryImpl implements ScheduleRepositoryInterface
{
    public function findScheduleByUserId(string $userId, string $type): ?Schedule
    {
        $schedule = Schedule::where('user_id', $userId)
            ->where('type', $type)
            ->first();

        return $schedule ?? null; // <= ??
    }
}

DIコンテナ機能の利用

同時に、この2つのクラスは、LaravelのDIコンテナ機能を利用して、(すなわち、ServiceProviderを介して、) singleton結合が行われていることとします。

AppServiceProvider.php
// 省略
class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->singleton(
            ScheduleRepositoryInterface::class,
            ScheduleRepositoryImpl::class
        );
    }
    
    // 省略
}

こうすることによって、ChristmasServiceScheduleRepositoryImpl はinterfaceを介した緩やかな依存関係にあることになります。この状態を 「疎結合」 と呼びます。

単体テストで目指すこと

そもそも単体テストで目指すことは何か

「単体テスト」で目指すところは、テスト対象のクラスや関数が 期待通りの振る舞いを行うことができているかどうかを検査することです。

よって、テストコードを書いて実行したとき、その結果が「他のクラスで行われている実装」や「アプリケーションの外側で提供されているデータ」がどのようなものであるか、ということに依存して変化してしまっているとすると、どうやら前述の「目的」を達成しているとは言い切れないということになってしまいそうです。

実務的な作業負担に視点を置き換えて考えると、「あるクラスの実装や実行結果が変化したことに伴って、他のクラスのテストコードが落ちる」という可能性を含んだものになってしまうわけです。すなわち、せっかく品質や保守性のために別々のクラスに実装を切り出しているにも関わらず、1つのクラスの変化で両方のクラスの実装品質が変わってしまうことになります。

これでは別々のクラスであることの意味付けが薄れてしまうのですが、とにかく、本来的にはそれぞれのクラスの品質を個別に追求できることが望ましいわけです。

どうやって実現するか

しかしながら実際問題、今回のテスト対象クラスである ChristmasService の関数は、ScheduleRepositoryImpl に記述されている処理を利用して動作するように実装されています。

繰り返しになりますが、今回の例のようにそのままテスト対象関数を呼び出すとすると、ChristmasServiceの関数の振る舞いのみをテストしたいのにも関わらず、その結果はScheduleRepositoryImplの処理に依存してしまうということになります。

この一見 相反(?)しているように見える状況をくぐり抜け、当初の目的を達成できるようなテストコードを実装し、細かくクラスを分けて処理を実装したことによってもたらされる 「品質」と「保守性」が維持されていることを検査する ためには、どうしたらいいでしょうか?

ここで、今回はモック化の概念を利用します。

モックを利用したテストコードの実装

モックを使わない場合

一旦、望ましくない例を具体的に観察するために、シンプルに ChristmasServicehasPrivateSchedule を素直に実行する形で、振る舞い(の一部)を検査しようとしてみます。
ざっくり、下記のようなテストコードが考えられるかと思います。

ChristmasServiceTest.php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Services\ChristmasService;
use Tests\TestCase;

class ChristmasServiceTest extends TestCase
{
    private ChristmasService $service;

    protected function setUp(): void
    {
        parent::setUp();

        $this->service = app(ChristmasService::class);
    }

    public function test_hasPrivateScheduleのテスト_userIdに合致するレコードを探した結果nullならfalseを返す()
    {
        $userId = 'zaki252';

        $expected = false;
        $actual = $this->service->hasPrivateSchedule($userId);

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

しかしこれでは、これまでに記載していた通り、 hasPrivateSchedule の実行中に ScheduleRepositoryImplfindScheduleByUserId もそのまま実装通りに読み込まれます。

そうすると、テスト用データベースの中身にuser_id = zaki252かつprivateScheduleが保存されて「いない」場合は期待値通りになるのですが、
保存されて「いる」場合はモデルインスタンスが返されることになるわけですから、結果として$actualtrueになるはずです。
よって、このテストは失敗に終わる可能性を含んだものになります。

ぶっちゃけ言うと、個人的にはtrueが返されて失敗に終わってもらった方がありがたいのですが、 今回のテストとしてはそういうわけにもいきません。

テスト対象関数に期待する振る舞い

ここで、ChristmasServicehasPrivateSchedule に期待される振る舞いを整理すると、

  1. ScheduleRepositoryImplfindScheduleByUserId$userId$type='private'を渡しつつ、呼び出しを行う
  2. Scheduleモデルのインスタンスが返却されればtrueを、されなければfalseを返却する

この2つのみになるかと思うので、これらの挙動だけをテストすることを目指します。

依存関係にあるクラスのモック化

テストコードのsetUp()に下記のように追記し、ChristmasService の中で使われる ScheduleRepositoryInterface の実装内容をジャックする準備を行います。

つまり、「ScheduleRepositoryがもし〇〇であると仮定すれば、ChristmasServiceはこういう動きをすることが期待される」というテストコードを(最終的には振る舞いを包括する方向で)書いていく、ということです。

use App\Repositories\ScheduleRepositoryImpl;
use App\Repositories\ScheduleRepositoryInterface;
use App\Services\ChristmasService;
use Mockery\MockInterface;
use Tests\TestCase;

// 中略

private ChristmasService $service;

private MockInterface $scheduleRepositoryMock;

protected function setUp(): void
{
    parent::setUp();

    // ①関連クラスのモックを生成
    $this->scheduleRepositoryMock = $this->mock(ScheduleRepositoryImpl::class);
    // ②作成したモックのインスタンスでDIコンテナを上書き
    $this->app->instance(ScheduleRepositoryInterface::class, $this->scheduleRepositoryMock);

    // ③テスト対象サービスのインスタンスを生成
    $this->service = app(ChristmasService::class);
}

テストの実行

下記の記述を用いて、テスト中に実行される「クラスの外側の関数の内容」を仮定をもって改変します。

public function test_hasPrivateScheduleのテスト_データ取得関数を呼び出しその結果がnullならfalseを返す()
{
    $userId = 'zaki252';

    /**
     * `findScheduleByUserId`をモックする
     * - この関数が呼び出されて、
     * - `$userId`と`private`が渡された結果、
     * - `null`が返却される
     * 「ということにする」
     */
    $this->scheduleRepositoryMock
        ->shouldReceive('findScheduleByUserId')
        ->with($userId, 'private')
        ->andReturn(null);

    $expected = false;
    $actual = $this->service->hasPrivateSchedule($userId);

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

テスト実行結果としては、下記のようになります。

$ php artisan test tests/Unit/ChristmasServiceTest.php

   PASS  Tests\Unit\ChristmasServiceTest
  ✓ has private scheduleのテスト データ取得関数を呼び出しその結果がnullならfalseを返す                                                                                                                                                                                           0.41s  

  Tests:    1 passed (1 assertions)
  Duration: 0.64s

「テストの中にフィクションを混ぜていいのか?」と思う方もいらっしゃるかもしれませんが、嘘をついているわけではなく あくまで仮定ですし、
先ほど記載した下記の「関数に求める振る舞い」について しっかり検査できているのであれば、問題ありません。

  1. ScheduleRepositoryImplfindScheduleByUserId$userId$type='private'を渡しつつ、呼び出しを行う
  2. Scheduleモデルのインスタンスが返却されればtrueを、されなければfalseを返却する

あとは、これまでの同様の手順でtrueのパターン(実際にそんなケースがあるのかどうかは別として)を検査するテストコードの実装を終えれば、単体テストで行おうとしていたことは達成されていると捉えることができるはずです。

「密結合」な場合はどうなるか

最後に、ChristmasService に下記のような「DIコンテナを利用しない」「疎結合ではない」実装コードがある場合を考えます。

public function hasJobSchedule(string $userId): bool
{
    $repository = new ScheduleRepositoryImpl();

    $schedule = $repository->findScheduleByUserId($userId, 'job');

    return ($schedule) ? true : false;
}

このパターンだと、ScheduleRepositoryImpl のインスタンスはこの関数の実装の中で生成されるため、モック化を行う余地がありません。
同様に、コンストラクタを利用して具象(実装)クラスを注入する場合でも、そのクラスのインスタンスの生成は ChristmasService のインスタンス生成のタイミングで行われることから、介入しにくい依存関係となります。
結果として、「単体テスト」は実施困難なものとなり、個別の品質追求は非常に難しくなります。

まとめ

個々の品質への集中

今回の記事では、「モック化を行うことで単体テストが書きやすくなる」ということについて、自分なりにまとめてみました。
DIの考え方を利用し、依存関係を「疎」にすることで、細かく責務を分けて実装されたそれぞれのクラスが、「自身の品質に集中して変化していくことのできる」状態を作り出すことが可能 です。
加えて、単体テストにおいてモックを利用することによって、その状態が保持されているかどうか、すなわち、各クラスや関数の振る舞いに焦点を当てて検査を行う ことができます。

終わりに

私個人に関して言えば、アーキテクチャ、デザインパターン、パッケージ構成に関連する話は、凝りすぎてしまうと本来の目的(品質や保守性の追求)と手段(どういう構成が優れているか)について 優先順位が逆転しやすく、ついつい衒学的な向き合い方になりがちな分野だと思っています。

好奇心を発揮することやモダンな開発を目指すこと、それら自体も大事なことですが、初心を忘れず、「その先にどんな実益があるのか」を意識した技術追究や意思決定を行うことの出来るエンジニアを目指して、今後も頑張っていきたいなと思います。

Arsaga Developers Blog

Discussion