はじめに
今回のゴールは、「テストが書きにくいコード」を見分け、テストしやすい形に作り変えられるようになることです。前回までは、Cart のように入力を渡せば結果が決まるコードを扱ってきました。こうしたコードは素直にテストできます。
しかし実際のアプリには、「今日がセール期間なら割引する」「在庫を外部のAPIに問い合わせる」のように、コードの外側にあるものに結果が左右される処理がたくさんあります。この回では、そうした処理をテストできるようにする**依存性注入(DI)**という考え方を学びます。
この回を読み終えると、次のことができるようになります。
- テストしにくいコードの典型的な特徴(現在時刻・
newの直書き・static呼び出し)を見分けられる - 依存性注入で、外部の要素をコンストラクタから渡す形に書き換えられる
- 時刻を固定して、「セール開始の瞬間」のような境界をテストできる
題材は、カートに新しく加える「期間限定セールの割引計算」です。
動作確認環境は前回と同じ、PHP 8.4.26、Composer 2.10.3、PHPUnit 13.3.5 です。記事中の実行結果は、すべてこの環境で実際に動かして取得したものです。
テストしにくいコードの実例
仕様:期間限定セール
カートに、次の仕様のセール割引を追加することになりました。
- セール期間は2026年11月20日 0:00:00 から 11月30日 23:59:59 まで
- 期間中は、合計金額から20%を割り引く(割引額の1円未満は切り捨て)
- 期間外は、割り引かない
まずは素直に書いてみる
src/Sale/SaleDiscount.php に、仕様どおりのクラスを作ります。
<?php
declare(strict_types=1);
namespace App\Sale;
use DateTimeImmutable;
final class SaleDiscount
{
public function apply(int $amount): int
{
$now = new DateTimeImmutable();
$start = new DateTimeImmutable('2026-11-20 00:00:00');
$end = new DateTimeImmutable('2026-11-30 23:59:59');
if ($now < $start || $now > $end) {
return $amount;
}
return $amount - intdiv($amount * 20, 100);
}
}
new DateTimeImmutable() は、引数を省略すると「今この瞬間」の日時になります。一見、何の問題もなさそうなコードです。
テストを書こうとすると困る
セール期間中に20%引きになることを確かめるテストを書いてみます。
public function test_セール期間中は20パーセント引きになる(): void
{
$discount = new SaleDiscount();
$this->assertSame(800, $discount->apply(1000));
}
F 1 / 1 (100%)
There was 1 failure:
1) Tests\Sale\SaleDiscountTest::test_セール期間中は20パーセント引きになる
Failed asserting that 1000 is identical to 800.
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
失敗しました。このテストを実行したのは2026年9月26日で、セール期間外だからです。コードは正しいのに、テストが失敗しています。
逆に、11月20日から30日の間に実行すれば、このテストは通ります。つまり、同じコードとテストなのに、実行する日によって結果が変わるのです。これでは、テストが失敗したときに「コードが壊れたのか、日付のせいなのか」を判断できません。
【スクショ:日付が原因でテストが失敗している実行結果】
何が問題なのか
問題は、apply() の中で new DateTimeImmutable() を呼び、現在時刻を自分で取りに行っていることです。テストの側から「今日は11月25日ということにして」と指示する方法がありません。
このように、テスト対象のコードが外部の何か(時刻、ファイル、DB、外部APIなど)に直接依存していると、テストで条件をコントロールできなくなります。
実は、もう1つ隠れた依存があります。
new DateTimeImmutable()が返す時刻のタイムゾーンは、php.iniのdate.timezoneの設定で決まります。開発PCは日本時間、本番サーバーはUTCという環境の違いがあると、同じコードでも「セール開始」の瞬間が9時間ずれます。
依存性注入(DI)で、時刻を外から渡す
解決策はシンプルです。クラスの中で現在時刻を取りに行くのをやめて、外から渡してもらうようにします。この「必要なもの(依存)を、外から注入してもらう」設計を**依存性注入(Dependency Injection、DI)**と呼びます。
難しそうな名前ですが、やることは「コンストラクタの引数で受け取る」だけです。
ステップ1:「時計」のインターフェースを作る
まず、「現在時刻を教えてくれるもの」を表すインターフェースを定義します。src/Clock/Clock.php に作成します。
<?php
declare(strict_types=1);
namespace App\Clock;
use DateTimeImmutable;
interface Clock
{
public function now(): DateTimeImmutable;
}
インターフェースは「こういうメソッドを持っていること」という約束だけを決めたものです。中身の処理は書きません。
ステップ2:本番用の時計を作る
本番では、本物の現在時刻を返す時計を使います。src/Clock/SystemClock.php に作成します。
<?php
declare(strict_types=1);
namespace App\Clock;
use DateTimeImmutable;
use DateTimeZone;
final class SystemClock implements Clock
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('now', new DateTimeZone('Asia/Tokyo'));
}
}
タイムゾーンを Asia/Tokyo と明示しているので、サーバーの php.ini の設定に左右されません。先ほどの「隠れた依存」もここで解消できます。
ステップ3:SaleDiscount が時計を受け取るようにする
SaleDiscount を書き換えます。ついでに、コードに直書きしていたセール期間と割引率も、外から受け取るようにしました。
<?php
declare(strict_types=1);
namespace App\Sale;
use App\Clock\Clock;
use DateTimeImmutable;
final class SaleDiscount
{
public function __construct(
private readonly Clock $clock,
private readonly DateTimeImmutable $start,
private readonly DateTimeImmutable $end,
private readonly int $discountPercent,
) {
}
public function apply(int $amount): int
{
$now = $this->clock->now();
if ($now < $this->start || $now > $this->end) {
return $amount;
}
return $amount - intdiv($amount * $this->discountPercent, 100);
}
}
変わったのは次の2点です。
| 変更前 | 変更後 |
|---|---|
new DateTimeImmutable() で自分で時刻を取得 | コンストラクタで受け取った $this->clock に時刻を聞く |
| 期間と割引率がコードに直書き | コンストラクタで受け取る |
大事なのは、引数の型が SystemClock ではなく、インターフェースの Clock になっている点です。SaleDiscount から見ると「now() を持つものなら何でもよい」ので、本番用の時計の代わりに、テスト用の時計を渡せるようになります。
コンストラクタの引数に
private readonlyを付ける書き方は、PHP 8.0のコンストラクタプロモーションと、8.1のreadonlyプロパティを組み合わせたものです。プロパティの宣言と代入をまとめて書けます。
本番コードでの使い方
アプリ本体では、次のように本物の時計を渡して使います。
$tokyo = new DateTimeZone('Asia/Tokyo');
$discount = new SaleDiscount(
clock: new SystemClock(),
start: new DateTimeImmutable('2026-11-20 00:00:00', $tokyo),
end: new DateTimeImmutable('2026-11-30 23:59:59', $tokyo),
discountPercent: 20,
);
$payment = $discount->apply($cart->totalWithTax());
セール期間の日時にもタイムゾーンを指定し、SystemClock と揃えている点に注意してください。ここがずれると、比較の結果もずれてしまいます。
毎回
newで組み立てるのは手間に見えるかもしれません。Laravelなどのフレームワークでは、この組み立てを「DIコンテナ」が自動で行ってくれます。第7回のLaravel編で扱います。
時刻を固定して、境界をテストする
テスト用の「止まった時計」を作る
テストでは、いつも決まった時刻を返す時計を使います。テストでしか使わないクラスなので、tests/Support/FixedClock.php に置きます。
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Clock\Clock;
use DateTimeImmutable;
final class FixedClock implements Clock
{
public function __construct(
private readonly DateTimeImmutable $now,
) {
}
public function now(): DateTimeImmutable
{
return $this->now;
}
}
コンストラクタで渡された日時を、now() で返し続けるだけのクラスです。このように、本物の代わりにテストで使う簡単な実装を**フェイク(Fake)**と呼びます。
名前空間が Tests\Support なので、第2回で設定した autoload-dev(Tests\ → tests/)によって自動で読み込まれます。追加の設定は必要ありません。
テストを書き直す
tests/Sale/SaleDiscountTest.php を次のように書き直します。
<?php
declare(strict_types=1);
namespace Tests\Sale;
use App\Sale\SaleDiscount;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Tests\Support\FixedClock;
final class SaleDiscountTest extends TestCase
{
private function createDiscountAt(string $now): SaleDiscount
{
return new SaleDiscount(
clock: new FixedClock(new DateTimeImmutable($now)),
start: new DateTimeImmutable('2026-11-20 00:00:00'),
end: new DateTimeImmutable('2026-11-30 23:59:59'),
discountPercent: 20,
);
}
public function test_セール期間中は20パーセント引きになる(): void
{
$discount = $this->createDiscountAt('2026-11-25 12:00:00');
$this->assertSame(800, $discount->apply(1000));
}
public function test_セール期間外は割り引かない(): void
{
$discount = $this->createDiscountAt('2026-09-26 12:00:00');
$this->assertSame(1000, $discount->apply(1000));
}
}
createDiscountAt() は、「指定した日時にいることにした SaleDiscount」を作るヘルパーメソッドです。テストごとに違うのは「今が何時か」だけなので、そこだけを引数にしました。
これで、テストをいつ実行しても「今日は11月25日」「今日は9月26日」という前提で動かせます。
.. 2 / 2 (100%)
OK (2 tests, 2 assertions)
ここでは
setUp()ではなくヘルパーメソッドを使っています。前回お話ししたとおり、setUp()はすべてのテストで共通の準備に使うものです。今回は「今の時刻」がテストごとに違うので、引数で受け取れるヘルパーメソッドのほうが向いています。
データプロバイダで境界をまとめて確かめる
時刻を自由に決められるようになると、これまで確かめようがなかった境界の瞬間もテストできます。前回学んだデータプロバイダと組み合わせましょう。
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
// ...
/**
* @return array<string, array{string, int}>
*/
public static function セール境界のケース(): array
{
return [
'開始1秒前は定価' => ['2026-11-19 23:59:59', 1000],
'開始ちょうどは割引' => ['2026-11-20 00:00:00', 800],
'終了ちょうどは割引' => ['2026-11-30 23:59:59', 800],
'終了1秒後は定価' => ['2026-12-01 00:00:00', 1000],
];
}
#[DataProvider('セール境界のケース')]
#[TestDox('$now に1000円の商品は$expected円')]
public function test_セール期間の境界(string $now, int $expected): void
{
$discount = $this->createDiscountAt($now);
$this->assertSame($expected, $discount->apply(1000));
}
vendor/bin/phpunit --testdox tests/Sale
Sale Discount (Tests\Sale\SaleDiscount)
✔ セール期間中は 20パーセント引きになる
✔ セール期間外は割り引かない
✔ 2026-11-19 23:59:59 に1000円の商品は1000円
✔ 2026-11-20 00:00:00 に1000円の商品は800円
✔ 2026-11-30 23:59:59 に1000円の商品は800円
✔ 2026-12-01 00:00:00 に1000円の商品は1000円
OK (6 tests, 6 assertions)
【スクショ:–testdox で境界の4ケースを含むテストがすべて通った実行結果】
境界のテストが守ってくれるもの
境界のテストの効果を確かめるため、apply() の条件にわざとバグを入れてみます。> を >= と書き間違えた、というよくあるミスです。
// 正しいコード
if ($now < $this->start || $now > $this->end) {
// バグ入り:終了時刻ちょうどが期間外になってしまう
if ($now < $this->start || $now >= $this->end) {
....F.. 7 / 7 (100%)
There was 1 failure:
1) Tests\Sale\SaleDiscountTest::test_セール期間の境界@終了ちょうどは割引 with data ('2026-11-30 23:59:59', 800)
Failed asserting that 1000 is identical to 800.
FAILURES!
Tests: 7, Assertions: 7, Failures: 1.
「終了ちょうど」のケースだけが失敗し、バグの場所がピンポイントでわかりました(7件になっているのは、次に紹介する切り捨てのテストも含めているためです)。
このバグは、11月30日 23:59:59 のたった1秒間しか影響しません。人の手で確かめるのはまず不可能ですが、時刻を注入できるようにしたことで、確実にテストできるようになりました。
割引額の端数もテストしておく
最後に、仕様の「割引額の1円未満は切り捨て」も確かめておきます。
public function test_割引額の1円未満は切り捨てる(): void
{
$discount = $this->createDiscountAt('2026-11-25 12:00:00');
// 999円の20%は199.8円 → 割引額は199円 → 支払額は800円
$this->assertSame(800, $discount->apply(999));
}
計算の過程をコメントで残しておくと、期待値の 800 がどこから来たのかを後から読む人がすぐ理解できます。
同じ考え方が効く、ほかのパターン
現在時刻は一例にすぎません。「クラスの中で、外部の何かを自分で取りに行っている」コードは、どれも同じ方法でテストしやすくできます。代表的な3つのパターンを見ていきましょう。
パターン1:new の直書き(外部APIなど)
在庫を外部の在庫管理APIに問い合わせるクラスを考えます。
// テストしにくい
final class StockChecker
{
public function canOrder(string $sku, int $quantity): bool
{
$api = new InventoryApiClient('https://api.example.com');
return $api->fetchStock($sku) >= $quantity;
}
}
このままテストを実行すると、本物のAPIに通信してしまいます。APIが止まっていればテストは失敗しますし、在庫数は日々変わるので期待値も決められません。
時計と同じく、インターフェースを定義してコンストラクタで受け取る形に変えます。
interface InventoryApi
{
public function fetchStock(string $sku): int;
}
// テストしやすい
final class StockChecker
{
public function __construct(
private readonly InventoryApi $inventory,
) {
}
public function canOrder(string $sku, int $quantity): bool
{
return $this->inventory->fetchStock($sku) >= $quantity;
}
}
テストでは、配列で在庫数を持つだけのフェイクを渡します。
final class InMemoryInventoryApi implements InventoryApi
{
/** @param array<string, int> $stocks */
public function __construct(
private readonly array $stocks,
) {
}
public function fetchStock(string $sku): int
{
return $this->stocks[$sku] ?? 0;
}
}
// テストでの使い方
$checker = new StockChecker(new InMemoryInventoryApi(['APPLE-001' => 3]));
$checker->canOrder('APPLE-001', 3); // true(在庫ちょうど)
$checker->canOrder('APPLE-001', 4); // false(在庫不足)
通信は一切発生せず、「在庫3個」という状況を好きなように作れます。
パターン2:static メソッドの呼び出し
設定値をstaticメソッドで取得するコードもよく見かけます。
// テストしにくい
public function totalWithTax(): int
{
$rate = Config::get('tax_rate');
return intdiv($this->total() * (100 + $rate), 100);
}
staticメソッドは、インターフェースで差し替えることができません。テストで「税率8%の場合」を試すには、グローバルな設定そのものを書き換えるしかなく、ほかのテストにまで影響が出てしまいます。
この場合は、必要な値そのものをコンストラクタで受け取るのが一番シンプルです。
// テストしやすい
public function __construct(
private readonly int $taxRatePercent,
) {
}
SaleDiscount で割引率を受け取るようにしたのも、同じ考え方です。
パターン3:乱数
クーポンコードをランダムに作る処理も、実行するたびに結果が変わるためテストしにくいコードの代表です。PHP 8.2で追加された Random\Randomizer クラスを外から受け取るようにすると、テストでは乱数の「種(シード)」を固定できます。
use Random\Randomizer;
final class CouponCodeGenerator
{
public function __construct(
private readonly Randomizer $randomizer,
) {
}
public function generate(): string
{
return 'SALE-' . $this->randomizer->getBytesFromString('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 8);
}
}
use Random\Engine\Mt19937;
use Random\Randomizer;
// 本番:毎回ランダム
$generator = new CouponCodeGenerator(new Randomizer());
// テスト:シードを42に固定すると、何度実行しても同じ結果になる
$generator = new CouponCodeGenerator(new Randomizer(new Mt19937(42)));
$generator->generate(); // 'SALE-G6B9V7LM'
getBytesFromString() は、指定した文字の中からランダムに選んで文字列を作るメソッドで、PHP 8.3から使えます。ここでは見間違えやすい I・O・0・1 を除いた文字を指定しています。
シードを固定したときの具体的な値に頼るテストは、読む人にとって「なぜこの値なのか」がわかりにくくなります。多くの場合は、
assertMatchesRegularExpression('/^SALE-[A-Z2-9]{8}$/', $code)のように、形式が正しいかを確かめるテストで十分です。
見分け方のまとめ
| テストしにくいサイン | 何に依存しているか | 直し方 |
|---|---|---|
new DateTimeImmutable()、time()、date() | 現在時刻 | Clock インターフェースを注入 |
メソッド内の new 〇〇Client() | 外部API・DB・ファイル | インターフェースを定義して注入 |
Config::get() などのstatic呼び出し | グローバルな状態 | 必要な値をコンストラクタで受け取る |
random_int()、rand() | 乱数 | Randomizer を注入 |
どの場合も、やることは同じです。「自分で取りに行く」のをやめて、「外から渡してもらう」。これが依存性注入の考え方のすべてです。
今回の完成コード
今回追加・変更したファイルは次のとおりです。第3回の Cart クラスとそのテストは変更していません。
php-test-cart/
├── src/
│ ├── Cart/
│ │ └── Cart.php
│ ├── Clock/
│ │ ├── Clock.php ← 追加
│ │ └── SystemClock.php ← 追加
│ └── Sale/
│ └── SaleDiscount.php ← 追加
└── tests/
├── Cart/
│ └── CartTest.php
├── Sale/
│ └── SaleDiscountTest.php ← 追加
└── Support/
└── FixedClock.php ← 追加
Clock.php、SystemClock.php、SaleDiscount.php、FixedClock.php は本文に掲載したコードがそのまま完成形です。テストクラスは本文で分けて紹介したので、全体をまとめて載せておきます。
tests/Sale/SaleDiscountTest.php
<?php
declare(strict_types=1);
namespace Tests\Sale;
use App\Sale\SaleDiscount;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Tests\Support\FixedClock;
final class SaleDiscountTest extends TestCase
{
private function createDiscountAt(string $now): SaleDiscount
{
return new SaleDiscount(
clock: new FixedClock(new DateTimeImmutable($now)),
start: new DateTimeImmutable('2026-11-20 00:00:00'),
end: new DateTimeImmutable('2026-11-30 23:59:59'),
discountPercent: 20,
);
}
public function test_セール期間中は20パーセント引きになる(): void
{
$discount = $this->createDiscountAt('2026-11-25 12:00:00');
$this->assertSame(800, $discount->apply(1000));
}
public function test_セール期間外は割り引かない(): void
{
$discount = $this->createDiscountAt('2026-09-26 12:00:00');
$this->assertSame(1000, $discount->apply(1000));
}
/**
* @return array<string, array{string, int}>
*/
public static function セール境界のケース(): array
{
return [
'開始1秒前は定価' => ['2026-11-19 23:59:59', 1000],
'開始ちょうどは割引' => ['2026-11-20 00:00:00', 800],
'終了ちょうどは割引' => ['2026-11-30 23:59:59', 800],
'終了1秒後は定価' => ['2026-12-01 00:00:00', 1000],
];
}
#[DataProvider('セール境界のケース')]
#[TestDox('$now に1000円の商品は$expected円')]
public function test_セール期間の境界(string $now, int $expected): void
{
$discount = $this->createDiscountAt($now);
$this->assertSame($expected, $discount->apply(1000));
}
public function test_割引額の1円未満は切り捨てる(): void
{
$discount = $this->createDiscountAt('2026-11-25 12:00:00');
// 999円の20%は199.8円 → 割引額は199円 → 支払額は800円
$this->assertSame(800, $discount->apply(999));
}
}
プロジェクト全体のテストを実行すると、第3回の分と合わせて15件がすべて通ります。
............... 15 / 15 (100%)
OK (15 tests, 18 assertions)
今回は
Clockインターフェースを自作しましたが、PHPには時計のインターフェースの共通規格「PSR-20」(psr/clockパッケージのClockInterface)もあります。フレームワークやライブラリと組み合わせる場面が増えてきたら、そちらに乗り換えることも検討してみてください。
まとめ
- クラスの中で現在時刻・外部API・static・乱数を直接使うと、テストで条件をコントロールできなくなる
- 依存性注入(DI)とは、必要なものを「自分で取りに行く」のをやめ、コンストラクタで「外から渡してもらう」こと
- 引数の型をインターフェースにしておくと、本番では本物、テストではフェイクを渡せる
- 時刻を固定できると、「開始ちょうど」「終了1秒後」のような境界の瞬間を確実にテストできる
次回予告
第5回は「テストダブル」です。今回は FixedClock や InMemoryInventoryApi を手書きしましたが、依存するものが増えるたびにクラスを作るのは大変です。PHPUnitの createStub() や createMock() を使えば、テスト用の偽物をその場で作れます。スタブ・モック・フェイクの違いと、それぞれをどう使い分けるかを解説します。
参考資料
- 第1回:なぜテストを書くのか
- 第2回:PHPUnitの環境構築と最初のテスト
- 第3回:読みやすいテストの書き方
- 第4回:テストしやすいコードの書き方(今回)
- 第5回:テストダブル
- 第6回:DBを使うテスト
- 第7回:Laravelのテスト基盤
- 第8回:Laravelのフェイク機能とPest
- 第9回:カバレッジとCI
- 第10回:TDDとレガシーコード (10/7 公開予定)
Analyzegear

