GoQSystem Tech Blog

GoQSystemのテックブログ

なぜDIを使うとテストが書きやすいのか?

GoQSystem Advent Calendar 2025」5日目

GoQSystemバックエンドエンジニアの松澤です。

「DIを使うとテストが書きやすくなる」この言葉を聞いたことがある方は多いと思います。

しかし、実際にコードでどう変わるのか、明確に説明できるでしょうか?

私自身、説明しようとすると言葉に詰まることがあったので、この機会に具体例を交えて整理してみました。

本記事で扱うこと

  • DIあり・なしの実装パターンの比較
  • テストがどのように楽になるかの具体例

本記事で扱わないこと

  • DIの解説
  • テスト以外のDIのメリット(保守性、拡張性など)

題材:商品の価格に割引を適用するクラス

「割引を計算するクラス」を依存として持つ、シンプルな例を題材にします。

サンプルコードの一覧

src/app/
├── Domain/
│   └── Discounts/
│       ├── DiscountCalculatorInterface.php
│       └── DiscountCalculator.php
└── Services/
  ├── WithDI/
  │   └── PriceService.php
  └── WithoutDI/
      └── PriceService.php

src/tests/
└── Feature/
  └── Services/
      ├── WithDIPriceServiceTest.php
      └── WithoutDIPriceServiceTest.php


DI を使わない場合

一見テストできていますが、問題があります。

本体コード

<?php

namespace App\Services\WithoutDI;

use App\Domain\Discounts\DiscountCalculator;

class PriceService
{
    public function getDiscountedPrice(int $price): int
    {
        $discountCalculator = new DiscountCalculator();
        $discount = $discountCalculator->calculate();

        return $price - $discount;
    }
}
<?php

namespace App\Domain\Discounts;

class DiscountCalculator
{
    public function calculate(): int
    {
        // 何らかの計算処理をして結果を返す
        return 100;
    }
}

テストコード

<?php

use App\Services\WithoutDI\PriceService;

it('割引が適用される', function () {
    $service = new PriceService();

    $result = $service->getDiscountedPrice(1000);

    expect($result)->toBe(900);
});

何が問題なのでしょうか?

DiscountCalculatorを差し替えられないため、以下のようなテストが書けません

DIなしでは書けないテスト例

<?php

it('割引が0円の場合', function () {
    // DiscountCalculatorは常に100を返すため、
    // 0円のケースを再現できない
});

it('割引計算で例外が発生した場合', function () {
    // DiscountCalculatorで例外を発生させる方法がない
});

また、PriceServiceがDiscountCalculatorに強く依存しているため、DiscountCalculatorの仕様変更がPriceServiceのテスト全体に影響します。

DI を使う場合

本体コード

依存をコンストラクタで受け取ることで、テスト時にモックを渡せるようになります。

※Laravelではサービスコンテナが依存を自動解決するため、コントローラーなどで型宣言するだけで利用できます。

<?php

namespace App\Services\WithDI;

use App\Domain\Discounts\DiscountCalculatorInterface;

class PriceService
{
    public function __construct(
        private DiscountCalculatorInterface $calculator
    ) {}

    public function getDiscountedPrice(int $price): int
    {
        $discount = $this->calculator->calculate();

        return $price - $discount;
    }
}
<?php

namespace App\Domain\Discounts;

interface DiscountCalculatorInterface
{
    public function calculate(): int;
}
<?php

namespace App\Domain\Discounts;

class DiscountCalculator implements DiscountCalculatorInterface
{
    public function calculate(): int
    {
        return 100;
    }
}

テストコード

DIを使うと、依存クラスの挙動を自由にコントロールできるため、様々なケースをテストできます。 そのため、異常系のケースも簡単に書けるようになります。

モックを使って挙動を制御

<?php

it('割引が適用される', function () {
    $calculator = Mockery::mock(DiscountCalculatorInterface::class);
    $calculator->shouldReceive('calculate')
        ->andReturn(100);

    $service = new PriceService($calculator);

    $result = $service->getDiscountedPrice(1000);

    expect($result)->toBe(900);
});

モックを使って異常系テスト

<?php

it('割引が0円の場合', function () {
    $calculator = Mockery::mock(DiscountCalculatorInterface::class);
    $calculator->shouldReceive('calculate')
        ->andReturn(0);

    $service = new PriceService($calculator);

    $result = $service->getDiscountedPrice(1000);

    expect($result)->toBe(1000);
});

it('割引計算で例外が発生する', function () {
    $calculator = Mockery::mock(DiscountCalculatorInterface::class);
    $calculator->shouldReceive('calculate')
        ->andThrow(new Exception('計算失敗'));

    $service = new PriceService($calculator);

    $service->getDiscountedPrice(1000);
})->throws(Exception::class, '計算失敗');

モックを使わずに正常系テストを書くことも選択できる

<?php

it('割引が適用される', function () {
    // 実装クラスを直接注入してテスト
    $calculator = new DiscountCalculator();
    $service = new PriceService($calculator);

    $result = $service->getDiscountedPrice(1000);

    expect($result)->toBe(900);
});

このように、DIなしでは 再現できないテストが簡単に書けるようになります。

補足:なぜインターフェースを使うのか

インターフェースを使わない場合の問題

具象クラスをそのまま使うと、モックを注入する際に問題が発生することがあります。

例:DiscountCalculatorのコンストラクタが複雑な場合

<?php

class DiscountCalculator
{
    public function __construct(
        private Database $db,
        private CacheService $cache,
        private ConfigRepository $config
    ) {}

    public function calculate(): int
    {
        // DBやキャッシュを使った複雑な計算
        return 100;
    }
}

class PriceService
{
    public function __construct(
        private DiscountCalculator $calculator  // 具象クラスを型宣言
    ) {}
}

テストでモックを渡そうとすると、コンストラクタの引数の扱いが難しくなる場合があります。

<?php

// テストでモックを渡そうとすると...
$mock = Mockery::mock(DiscountCalculator::class);
// コンストラクタの引数(Database, CacheService, ConfigRepository)を
// どう扱うかを考える必要があり、モック作成が複雑になる
$mock->shouldReceive('calculate')->andReturn(100);

$service = new PriceService($mock);

インターフェースを型宣言することで、実装の詳細(コンストラクタの依存関係など)に影響されず、モックも実装クラスも同じように扱えるようになり、テストが簡単に書けます。

まとめ

「DIを使うとテストが書きやすくなる」理由は、依存クラスの挙動を自由にコントロールできるようになるからです。 表にすると一目瞭然です。

観点 DIなし DIあり
依存クラスの差し替え できない 可能
異常系テスト できない 可能
責務の明確さ 低い 高い
テストの安定性 他クラスの変更に左右される 独立性が高い

「テスタビリティの高いコードを書きなさい」と言われた際の参考になればと思います。



GoQSystemでは一緒に働くエンジニア/PdMを募集しています!!


GoQSystemの採用情報はこちら↓

www.wantedly.com