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

ドメインロジックから始める: 最初の Red を書く

最初の 1 本は、邪魔になるものが何も無いところから書きます。データベースも、HTTP も、フレームワークも登場しません。純粋な計算だけが相手です。

ここから始めるのは、TDD のサイクルそのものに集中するためです。環境の都合でテストが落ちると、落ちた理由が仕様なのか設定なのか分からなくなります。

この章から第 3 部までは、会議室の予約という 1 つの題材を育てます。

この章で学ぶこと

  • 純粋なロジックで Red-Green-Refactor を 1 周する手順
  • Triangulation で境界条件を追い込む進め方
  • テスト名を仕様の一覧として使う書き方
前提知識

02 Red-Green-Refactor の 3 拍と 3 戦略を使います。Pest の記法は説明しないので、初めてなら先に下の参照先を見てください。

この章で扱わないこと

観点参照先
Pest の記法、テストの実行方法Pest でテストを書く
値オブジェクトとしての設計 (不変性・等価性・自己検証)値オブジェクト
値オブジェクトのテストで何を assert するかテスト戦略 の「値オブジェクトのテスト」
ドメイン層のテストを DB なしで動かすことの意義テスト戦略 の「なぜドメイン層のテストが「DBなし」で実行できることが重要なのか」
日本語のテストメソッド名、テストデータビルダー同上

前の 2 つとの違いは、この章が書く順序を扱っていることです。値オブジェクトをどう設計するかではなく、テストを先に置くと設計が何を要求されるかを見ます。

題材: 会議室の予約

会議室を時間帯で予約します。同じ部屋で時間帯が重なる予約は受け付けません。

最初に必要なのは「2 つの時間帯が重なるか」を判定するところです。予約の保存も、利用者への通知も、この判定ができてからの話です。

最初の Red

まだ何もありません。器だけ作ります。

final readonly class TimeRange
{
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end,
) {}

public function overlaps(TimeRange $other): bool
{
return false;
}
}

false を返すのは、最初の Red を仕様側の理由で落とすためです。メソッドが無い状態だと型エラーで落ちますが、それは「仕様を満たしていない」とは別の落ち方です。

テストで日時を組み立てる回数が多いので、短く書けるヘルパーを 1 つ置きます。Pest では tests/Pest.php に書くと全テストから呼べます。

// tests/Pest.php
function at(string $time): DateTimeImmutable
{
// 時刻だけ渡したときは基準日を補う
return new DateTimeImmutable(
str_contains($time, '-') ? $time : "2026-09-21 {$time}",
);
}

基準日を固定するのは、テストが実行日に左右されないようにするためです。この連載では以降もこの at() を使います。

最初のテストは、誰が見ても重なる 2 つです。

test('完全に重なる時間帯は重なりと判定する', function () {
$a = new TimeRange(at('10:00'), at('11:00'));
$b = new TimeRange(at('10:00'), at('11:00'));

expect($a->overlaps($b))->toBeTrue();
});

走らせます。false が返るので落ちます。期待した理由で赤になりました。

Fake It で通す

いちばん小さい一歩で緑にします。

public function overlaps(TimeRange $other): bool
{
return true;
}

通ります。同時に、明らかな重複が残りました。テストは「重なる」を期待し、実装は無条件に「重なる」と答えています。テストの期待値が実装にそのまま書いてある状態です。

この重複が、次に何をすべきかを教えます。

2 例目で一般化を迫る

重ならない例を足します。

test('離れた時間帯は重なりと判定しない', function () {
$a = new TimeRange(at('10:00'), at('11:00'));
$b = new TimeRange(at('13:00'), at('14:00'));

expect($a->overlaps($b))->toBeFalse();
});

true を返す実装では通りません。2 つの例が両方を否定するので、定数では答えられなくなりました。ここで初めて一般化します。

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

2 例とも通ります。

この式は最初から書けました。ただし Fake It を挟まずに書くと、テストと実装のあいだに重複が現れません。次の一歩を重複から決める手順が、そこで使えなくなります。

境界を決める

いまの実装は < を使っています。<= ではありません。この違いは、時間帯が接しているときに現れます。

10 時から 11 時までの予約と、11 時から 12 時までの予約は、重なっているでしょうか。

これは実装の問題ではなく、仕様の問題です。会議室なら「11 時ちょうどに退室して次が入る」という運用があり得ます。決めないとテストが書けません。

決めたらテストにします。

test('終了時刻と開始時刻が同じなら重ならない', function () {
$a = new TimeRange(at('10:00'), at('11:00'));
$b = new TimeRange(at('11:00'), at('12:00'));

expect($a->overlaps($b))->toBeFalse();
});

このテストは、いまの実装で通ります。通るテストを書くことに意味があるのかと思うかもしれませんが、意味は 2 つあります。

**決めたことを固定します。**誰かが <= に書き換えたら、このテストが落ちます。境界の判断が記録として残ります。

**仕様の一覧になります。**テスト名を並べると、この判定が何を約束しているかが読めます。

先に書いたテストが最初から緑だった場合は、実装を一時的に壊して落ちることを確かめます<<= に変えて赤くなるなら、このテストは確かに境界を見ています。落ちなければ、テストが何も検証していないということです。

残りの配置を埋める

2 つの時間帯の配置は、境界を含めて 6 通りあります。テストで並べます。

test('片方が他方を完全に含むなら重なる', function () {
$a = new TimeRange(at('09:00'), at('18:00'));
$b = new TimeRange(at('10:00'), at('11:00'));

expect($a->overlaps($b))->toBeTrue();
expect($b->overlaps($a))->toBeTrue();
});

test('後ろだけがはみ出していても重なる', function () {
$a = new TimeRange(at('10:00'), at('12:00'));
$b = new TimeRange(at('11:00'), at('13:00'));

expect($a->overlaps($b))->toBeTrue();
});

test('開始時刻だけが同じなら重なる', function () {
$a = new TimeRange(at('10:00'), at('11:00'));
$b = new TimeRange(at('10:00'), at('10:30'));

expect($a->overlaps($b))->toBeTrue();
});

すべて通ります。実装は変わりません。それでよいのです。この 3 つは新しい振る舞いを要求しているのではなく、既にある振る舞いの範囲を記録しています。

もう 1 つ、この段階で気づくことがあります。「終了が開始より前の時間帯」を作れてしまいます。

$broken = new TimeRange(at('15:00'), at('09:00'));

判定の話ではなく、そもそも存在してはいけない値です。ここで初めて、コンストラクタに検査が要ることが見えます。

test('終了が開始より前なら作れない', function () {
expect(fn () => new TimeRange(at('15:00'), at('09:00')))
->toThrow(InvalidArgumentException::class);
});

このテストは落ちます。ここから次のサイクルが始まります。

ここまでで得られたもの

  • 振る舞いを固定するテストが 6 本
  • そのうち 2 本は、境界の判断を明文化したもの
  • コンストラクタの検査という、書き始めた時点では見えていなかった要求

**6 本のテスト名を並べると、TimeRange の仕様書になります。**これは副産物ではなく、テストを先に書いたことの主な成果です。

ここまでデータベースもフレームワークも出てきていません。テストは一瞬で終わり、環境の都合で落ちることもありません。純粋なロジックを先に固める価値はここにあります。

次の章では、この外側にあるもの (現在時刻、保存、通知) を扱います。そこからテストの書き方が変わります。

まとめ

  • 器だけ先に作り、仕様側の理由で落ちる Red を作ります
  • Fake It で通すと、テストと実装の重複が残ります。その重複が次の一歩を教えます
  • 2 例目が定数を否定したときに、初めて一般化します
  • 境界は実装の問題ではなく仕様の問題です。決めてテストに固定します
  • 最初から緑だったテストは、実装を一時的に壊して落ちることを確かめます
  • テスト名の並びが、そのまま仕様の一覧になります

次に読む