メインコンテンツまでスキップ

境界を差し替える: 時刻・永続化・通知

純粋なロジックの外側には、自分では決められないものがあります。いま何時か。保存できたか。通知が届いたか。

これらはテストの敵ではありません。どこを境界にするかを決める機会です。境界の位置は、テストを書こうとした瞬間に問われます。

この章では 3 つの境界を順に切り、04 章で分類したダブルを当てはめます。

この章で学ぶこと

  • 現在時刻を外から渡す 2 つの形と、その使い分け
  • 保存を配列で置き換える Fake の書き方
  • 外部への通知を Spy で確かめる形
  • 実データベースを使わずにどこまで確かめられるか
前提知識

04 テストダブル の 5 分類と、06 ドメインロジックから始めるTimeRange を使います。

この章で扱わないこと

観点参照先
RefreshDatabase / assertDatabaseHas / Mail::fake() などの仕組みPest でテストを書く
外部との通信をテストから出す考え方 (Laravel の道具立て)同上
リポジトリパターンの設計、永続化との境界リポジトリパターン
実データベースを使う統合テスト、層ごとのテスト配分テスト戦略
モックを使いすぎたときに何が起きるかテスト戦略 の「モックの使い方と使いすぎの弊害」
日時とタイムゾーンの扱い日時とタイムゾーン

この章の開始時点のコード

06 章で、時間帯の重なりを判定するところまで作りました。

final readonly class TimeRange
{
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end,
) {
if ($end <= $start) {
throw new InvalidArgumentException('終了は開始より後である必要があります');
}
}

public function overlaps(TimeRange $other): bool
{
return $this->start < $other->end && $other->start < $this->end;
}
}

ここから「予約を受け付ける」という操作を作ります。

この章ではテストの組み立てが増えるので、06 章の at() に 3 つ足します。

// tests/Pest.php
function rangeAt(string $start, string $end): TimeRange
{
return new TimeRange(at($start), at($end));
}

function pastRange(): TimeRange
{
return rangeAt('2026-09-20 09:00', '2026-09-20 10:00');
}

function reservationAt(string $start, string $end): Reservation
{
return new Reservation(new RoomId('room-1'), rangeAt($start, $end));
}

pastRange() が返すのは基準日の前日です。この章で使う FixedClock は 2026-09-21 を指すので、必ず過去になります。

境界は 3 つ

予約を受け付けるとき、次のことが起きます。

  1. 過去の時間帯でないことを確かめる → いま何時かを知る必要がある
  2. 同じ部屋の既存の予約と重ならないことを確かめる → 保存済みのものを読む必要がある
  3. 受け付けたことを利用者に伝える → 外部へ出す必要がある

3 つとも、純粋な計算では閉じません。それぞれ別の切り方をします。

時刻: 引数で渡すか、依存として渡すか

「過去の時間帯は予約できない」を確かめるには、現在時刻が要ります。

素直に書くとこうなります。

if ($range->start < new DateTimeImmutable()) {
throw new PastTimeRangeException();
}

これはテストできません。テストを実行した時刻によって結果が変わるので、期待値を固定できないからです。03 章の型 2 です。

切り方は 2 つあります。

引数で受け取る形は、いちばん軽い方法です。

public function execute(RoomId $roomId, TimeRange $range, DateTimeImmutable $now): Reservation

呼ぶ側が時刻を決めるので、テストは値を渡すだけです。依存が増えません。判定に使う時刻が引数として見えるので、「いつの時点での判定か」がコードから読めます。

依存として受け取る形は、時刻を使う箇所が複数あるときに向きます。時計を表すインターフェースは PSR-20 として Psr\Clock\ClockInterface の名前で決まっており、now(): DateTimeImmutable の 1 メソッドだけを持ちます。言語に同梱されてはいないので、使うなら psr/clock を入れます。

final class ReserveRoom
{
public function __construct(private ClockInterface $clock) {}

public function execute(RoomId $roomId, TimeRange $range): Reservation
{
if ($range->start < $this->clock->now()) {
throw new PastTimeRangeException();
}
// ...
}
}

テストでは固定値を返すものを渡します。

final class FixedClock implements ClockInterface
{
public function __construct(private DateTimeImmutable $now) {}

public function now(): DateTimeImmutable
{
return $this->now;
}
}
test('過去の時間帯は予約できない', function () {
$useCase = new ReserveRoom(new FixedClock(at('2026-09-21 10:00')));

expect(fn () => $useCase->execute(new RoomId('room-1'), pastRange()))
->toThrow(PastTimeRangeException::class);
});

FixedClockStub です。決まった値を返すだけで、記録も検証もしません。

**どちらを選ぶか。**メソッド 1 つが時刻を使うだけなら引数で足ります。複数のメソッドが使う、あるいは呼び出し元が時刻を知る必要がないなら、依存として受け取ります。先に依存を増やさないことを既定にして、必要になってから移します。

本章はこの後も依存として受け取る形で進めます。予約の受け付けで時刻を使うのは 1 か所ですが、次の節で足す保存と通知も同じように渡すので、形を揃えた方が読みやすくなります。

永続化: 配列で動く Fake

既存の予約と重ならないことを確かめるには、保存済みのものを読む必要があります。

まず、読み書きの口をインターフェースにします。

interface ReservationRepository
{
/** @return Reservation[] */
public function findByRoom(RoomId $roomId): array;

public function add(Reservation $reservation): void;
}

テスト用には、配列で動く実装を書きます。

final class InMemoryReservationRepository implements ReservationRepository
{
/** @var Reservation[] */
private array $items = [];

public function findByRoom(RoomId $roomId): array
{
return array_values(array_filter(
$this->items,
fn (Reservation $r) => $r->roomId->equals($roomId),
));
}

public function add(Reservation $reservation): void
{
$this->items[] = $reservation;
}
}

これは Fake です。Stub と違い、書いたものを後から読めます。だから「保存してから読む」という 2 段階のテストが書けます。

test('既存の予約と重なる時間帯は受け付けない', function () {
$roomId = new RoomId('room-1');
$repository = new InMemoryReservationRepository();
$repository->add(reservationAt('10:00', '11:00'));
$useCase = new ReserveRoom(new FixedClock(at('2026-09-21 09:00')), $repository);

expect(fn () => $useCase->execute($roomId, rangeAt('10:30', '11:30')))
->toThrow(RoomAlreadyReservedException::class);
});

test('重ならなければ受け付けて保存する', function () {
$roomId = new RoomId('room-1');
$repository = new InMemoryReservationRepository();
$useCase = new ReserveRoom(new FixedClock(at('2026-09-21 09:00')), $repository);

$useCase->execute($roomId, rangeAt('13:00', '14:00'));

expect($repository->findByRoom($roomId))->toHaveCount(1);
});

2 本目は 状態検証です。「add() が呼ばれたこと」ではなく「保存されたものが読めること」を見ています。保存の手段が変わっても、結果が同じなら通ります。

通知: Spy で出たことを見る

3 つ目の境界は外向きです。ここは状態では確かめられません。通知を送ったかどうかは、自分の側の状態には残らないからです。

04 章の Spy を使います。

final class SpyNotifier implements Notifier
{
/** @var array<array{to: string, message: string}> */
public array $calls = [];

public function notify(string $to, string $message): bool
{
$this->calls[] = ['to' => $to, 'message' => $message];

return true;
}
}
test('受け付けたら申込者に通知する', function () {
$roomId = new RoomId('room-1');
$repository = new InMemoryReservationRepository();
$spy = new SpyNotifier();
$useCase = new ReserveRoom(new FixedClock(at('2026-09-21 09:00')), $repository, $spy);

$useCase->execute($roomId, rangeAt('13:00', '14:00'));

expect($spy->calls)->toHaveCount(1);
expect($spy->calls[0]['to'])->toBe('taro.test@example.com');
});

test('重なりで断ったときは通知しない', function () {
$roomId = new RoomId('room-1');
$repository = new InMemoryReservationRepository();
$repository->add(reservationAt('10:00', '11:00'));
$spy = new SpyNotifier();
$useCase = new ReserveRoom(new FixedClock(at('2026-09-21 09:00')), $repository, $spy);

expect(fn () => $useCase->execute($roomId, rangeAt('10:30', '11:30')))
->toThrow(RoomAlreadyReservedException::class);
expect($spy->calls)->toBeEmpty();
});

2 本目が大事です。**送らないことを確かめています。**これは Spy の記録が空であることで見ます。Stub では書けません。

3 つの境界を束ねる

結果として、ユースケースはこの形になりました。

final class ReserveRoom
{
public function __construct(
private ClockInterface $clock,
private ReservationRepository $repository,
private Notifier $notifier,
) {}

public function execute(RoomId $roomId, TimeRange $range): Reservation
{
// 判定 → 保存 → 通知
}
}

**コンストラクタを読むだけで、このクラスが外の何に触るかが分かります。**時刻と、保存と、通知の 3 つです。それ以外には触りません。

これはテストのために作った形ではありません。テストを先に書いたら、この形しか書けなかったというだけです。03 章で「テストが設計を圧す」と呼んだものの、具体的な現れ方です。

なぜ実データベースを使わないのか

ここまで一度もデータベースを起動していません。意図してそうしています。

この章が確かめたいのは、予約を受け付ける条件です。過去なら断る、重なれば断る、受け付けたら通知する。これらは SQL とは無関係の判断です。

一方で、確かめていないこともあります。ReservationRepository の本物の実装が正しく動くかどうかです。SQL の条件が間違っていれば、findByRoom() は違うものを返します。Fake は正しく動くので、テストは通ったまま本番で壊れます。

**Fake でのテストは、本物の実装のテストを不要にしません。**本物のリポジトリには、実データベースに対する別のテストが要ります。そこは本ガイドの範囲外で、上の参照先が扱います。

境界を切る目的は、2 種類のテストを分けることです。条件の判断は速く何度でも回し、データベースとのやり取りは回数を絞って確かめます。

まとめ

  • 時刻は引数か依存として外から渡します。使う箇所が 1 つなら引数、複数なら ClockInterface のような依存にします
  • 保存は Fake で置き換えます。書いたものを読めるので、状態検証ができます
  • 外向きの通知は状態に残らないので Spy を使います。送らないことの確認も Spy でできます
  • コンストラクタが、そのクラスが触る外部の一覧になります
  • Fake でのテストは本物の実装を保証しません。実データベースへのテストは別に要ります

次に読む