境界を差し替える: 時刻・永続化・通知
純粋なロジックの外側には、自分では決められないものがあります。いま何時か。保存できたか。通知が届いたか。
これらはテストの敵ではありません。どこを境界にするかを決める機会です。境界の位置は、テストを書こうとした瞬間に問われます。
この章では 3 つの境界を順に切り、04 章で分類したダブルを当てはめます。
この章で学ぶこと
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 つ
予約を受け付けるとき、次のことが起きます。
- 過去の時間帯でないことを確かめる → いま何時かを知る必要がある
- 同じ部屋の既存の予約と重ならないことを確かめる → 保存済みのものを読む必要がある
- 受け付けたことを利用者に伝える → 外部へ出す必要がある
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);
});
FixedClock は Stub です。決まった値を返すだけで、記録も検証もしません。
**どちらを選ぶか。**メソッド 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 でのテストは本物の実装を保証しません。実データベースへのテストは別に要ります
次に読む
- 08 歩くスケルトン — 同じ題材を、今度は外側から 1 本貫きます
- リポジトリパターン — 本物のリポジトリをどう設計するか