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

テストが設計を圧す: 書きにくさは何のシグナルか

テストが書きにくいとき、対処は 2 通りあります。テスト側で何とかするか、コード側を直すかです。

前者は速く終わります。モックを重ねる、テスト用の分岐を足す、private をリフレクションで覗く。どれもその場では動きます。ただし、書きにくさを生んでいた構造はそのまま残ります。

TDD が設計に効くと言われるのは、この分かれ道が実装を決める前に来るからです。後からテストを書くなら、コードは既にあるので前者を選びがちです。先に書くなら、まだ何も無いので後者が自然になります。

この章は、書きにくさを 5 つの型に分け、型ごとに両方の対処を並べます。

この章で学ぶこと

  • テストが書きにくいときに現れる 5 つの型
  • 型ごとの「テストを歪める対処」と「設計を直す対処」
  • どちらを選ぶかの判断
前提知識

02 Red-Green-Refactor を読んでいることを前提にします。依存性注入を使ったことがあると、型 1 と型 2 の実感が変わります。

この章で扱わないこと

観点参照先
依存性注入の仕組みとコンテナの使い方依存性注入
依存の向きを逆転させる原則DIP と DI と IoC
日付と時刻、タイムゾーンの扱い日時とタイムゾーン
ダブルの種類の違い04 テストダブル

書きにくさは情報である

「テストが書きにくい」は、主観的な感想に見えて、実際にはコードの構造について具体的なことを言っています。

テストを書くという行為は、対象を外から使うことです。入力を与え、出力を見る。それが難しいということは、対象が外から使いにくいということです。テストは最初の利用者であり、使いにくさを最初に報告してきます。

以下の 5 つの型は、その報告の内容を分類したものです。

型 1: 依存が隠れている

対象の内部で、別のクラスを直接組み立てています。外から差し替える口がありません。

class OrderService
{
public function place(array $items): Order
{
$repository = new OrderRepository(); // ここ
$order = new Order($items);
$repository->save($order);

return $order;
}
}
class OrderService {
place(items: Item[]): Order {
const repository = new OrderRepository() // ここ
const order = new Order(items)
repository.save(order)
return order
}
}

テストを書こうとすると、本物の OrderRepository が動きます。

test('注文を確定できる', function () {
$service = new OrderService();

$order = $service->place([$item]);

expect($order->isPlaced())->toBeTrue();
});
it('注文を確定できる', () => {
const service = new OrderService()

const order = service.place([item])

expect(order.isPlaced()).toBe(true)
})

このテストは、データベースが起動していないと落ちます。**落ちた理由が仕様ではなく環境になります。**02 章で「期待した理由で落ちているか」を見ると書いたのは、ここで効いてきます。

依存を外から受け取る形に直すと、テストは渡すだけになります。

test('注文を確定できる', function () {
$service = new OrderService(new InMemoryOrderRepository());

$order = $service->place([$item]);

expect($order->isPlaced())->toBeTrue();
});
it('注文を確定できる', () => {
const service = new OrderService(new InMemoryOrderRepository())

const order = service.place([item])

expect(order.isPlaced()).toBe(true)
})
対処内容残るもの
テストを歪めるモジュール全体をモックに差し替える。テスト用のフラグを足す依存は隠れたまま。差し替えの記述がテストごとに増える
設計を直す依存をコンストラクタか引数で受け取る本番のコードからも、何に依存しているかが読める

設計を直す側を選ぶと、OrderService の宣言だけを見て依存が分かるようになります。テストのためだけの変更ではありません。

型 2: 時刻や乱数を直に呼んでいる

対象の中で現在時刻や乱数を取得しています。同じ入力でも結果が変わるので、期待値を書けません。

public function isExpired(): bool
{
return $this->expiresAt < new DateTimeImmutable(); // ここ
}
isExpired(): boolean {
return this.expiresAt < new Date() // ここ
}

このままだと、テストが実行時刻に左右されます。

test('期限を過ぎたトークンは無効になる', function () {
$token = new Token(expiresAt: new DateTimeImmutable('2026-09-21 10:00'));

expect($token->isExpired())->toBeTrue();
});

このテストは 2026-09-21 10:00 より前に実行すると落ちます。書いた本人の手元では通り、同僚の手元では落ちる、ということが起きます。

判定に使う時刻を引数で受け取ると、両側を確かめられます。

test('期限の前後で判定が変わる', function () {
$token = new Token(expiresAt: new DateTimeImmutable('2026-09-21 10:00'));

expect($token->isExpiredAt(new DateTimeImmutable('2026-09-21 10:01')))->toBeTrue();
expect($token->isExpiredAt(new DateTimeImmutable('2026-09-21 09:59')))->toBeFalse();
});
it('期限の前後で判定が変わる', () => {
const token = new Token(new Date('2026-09-21T10:00:00+09:00'))

expect(token.isExpiredAt(new Date('2026-09-21T10:01:00+09:00'))).toBe(true)
expect(token.isExpiredAt(new Date('2026-09-21T09:59:00+09:00'))).toBe(false)
})

境界の前後を両方書けるようになった点も重要です。時刻が動く実装では、片側しか確かめられません。

対処内容残るもの
テストを歪めるシステム時刻を偽装するライブラリを入れる。テストを実行時刻に依存させる実行する時間帯によって落ちるテストが残る。並列実行で不安定になる
設計を直す現在時刻を引数で受け取るか、時刻を返す依存として注入する「いつの時点で判定するか」がコードに明示される

時刻を外から渡す形にすると、テストは単に値を渡すだけになります。偽装の仕組みは要りません。この形は 07 章で具体的に作ります。

型 3: グローバルな状態を読み書きしている

静的変数、シングルトン、環境変数、スーパーグローバル。どれもテストごとに状態を初期化しないと、前のテストの結果が次に漏れます。

書きにくさは「テストの実行順によって結果が変わる」という形で現れます。単体では通るのに、全部走らせると落ちる。これが起きたら型 3 を疑います。

対処内容残るもの
テストを歪める各テストの前後でグローバルを手で初期化する初期化の漏れが新しいテストのたびに起きる。並列実行できない
設計を直す状態を引数か依存として受け渡すどのコードがその状態に触るのかが追える

型 4: setup が長い

1 つのテストを書くために、5 つも 6 つもオブジェクトを組み立てています。

これは 2 つの別々のことを指している可能性があります。

対象が持ちすぎている場合。 1 つのクラスが多くの協力者を必要としているなら、責務が集まりすぎています。分けると setup も分かれます。

組み立て方が下手な場合。 対象の設計は妥当で、テスト側の書き方が冗長なだけということもあります。この場合はテストデータビルダーのような道具で短くできます。

どちらかを見分けるには、setup に出てくるオブジェクトがそのテストの本題に関係しているかを見ます。関係しないものが並んでいるなら前者、必要なものを冗長に書いているだけなら後者です。

setup の長さを集約の境界のシグナルとして読む話は、13 章で扱います。

型 5: 外から見えない状態を確かめたい

テストしたい結果が、対象の外に出てきません。private なプロパティに入ったままだったり、内部のコレクションに積まれているだけだったりします。

対処内容残るもの
テストを歪めるリフレクションで private を読む。テストのためだけに getter を公開する内部構造にテストが結びつく。リファクタのたびにテストが落ちる
設計を直す外から観測できる形を考え直す。観測する必要が本当にあるのかを問う公開する範囲が、利用者にとって意味のある単位で決まる

この型は、確かめたいものが本当に結果なのかを問い直す機会でもあります。内部のコレクションに積まれたことではなく、積まれた結果として何が起きるかが本題なら、そちらを確かめます。

どちらの対処を選ぶか

設計を直す側が常に正しいわけではありません。判断の軸は 3 つあります。

変更の範囲が対象の内側で収まるか。 コンストラクタに引数を 1 つ足すだけなら、直す側を選びます。呼び出し元を 50 か所直す必要があるなら、そのタスクの範囲を超えます。

書きにくさが繰り返し出てくるか。 1 回きりならテスト側で処理して構いません。同じ形の書きにくさが 3 回目なら、構造の問題です。

テストを歪めた結果が、後で壊れるか。 実行順に依存するテストや、内部構造に結びついたテストは、後から必ず壊れます。壊れたときの原因が分かりにくいのも特徴です。ここは直す側に倒します。

TDD で書いていると、この判断が実装を書く前に来ます。まだ呼び出し元が無いので、直す側のコストが最も小さい時点です。これが「テストが設計を圧す」ということの中身です。

まとめ

  • テストの書きにくさは、対象が外から使いにくいという具体的な報告です
  • 5 つの型はそれぞれ別の構造的な原因を指しています。型を見分けると、対処が決まります
  • テスト側で歪める対処は速いですが、実行順への依存や内部構造への結びつきという形で後に残ります
  • 判断の軸は、変更範囲・繰り返しの有無・後で壊れるかどうかの 3 つです
  • TDD ではこの判断が実装前に来るので、直す側のコストが最小の時点で選べます

次に読む