ドメインロジックから始める: 最初の 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 例目が定数を否定したときに、初めて一般化します
- 境界は実装の問題ではなく仕様の問題です。決めてテストに固定します
- 最初から緑だったテストは、実装を一時的に壊して落ちることを確かめます
- テスト名の並びが、そのまま仕様の一覧になります
次に読む
- 07 境界を差し替える — 現在時刻・保存・通知を外から差し替えます
- 値オブジェクト —
TimeRangeのようなオブジェクトの設計そのものを扱います