メインコンテンツへスキップ
ブログ一覧に戻る
データ分析・KPI

TDD入門|失敗するテストから始める開発を、動く小例で理解する

2024年7月26日
最終更新: 2026年10月8日
7分で読めます

この記事でわかること

テストを先に書くだけでは、TDDの狙いは伝わりません。何を確かめるかを決め、期待した理由で失敗することを見てから実装し、通る状態を保って整理する流れを追います。

2026年10月8日にKent BeckのTDDの説明、Martin FowlerのTDD解説、Node.js v24の公式テストランナーを再確認した。TDDは全テストを先に書き切る儀式ではなく、確かめたい振る舞いを一つ選び、期待した理由で失敗することを確認し、実装して既存テストも通し、外からの振る舞いを保って必要な整理をする反復である。Greenは実行した期待の証拠で、未テストの要求、UI、権限、外部連携、仕様の誤り、セキュリティを自動で保証しない。

「テストが全部通ったのに、必要な条件を見落としていた」。テストは実行した内容を確かめますが、何を確かめるべきかまで自動で決めてはくれません。TDDでは、その期待する振る舞いを実装より先に言葉とテストへ落とし込みます。

TDDはTest-Driven Development、テスト駆動開発の略です。この記事では、予約枠の残数を求める架空の関数を使い、テストの失敗を確かめ、実装し、必要なら整理する小さなサイクルを追います。Node.jsの標準機能で実行できる例です。実際の予約システムや外部サービスは操作しません。

30秒で要点

  • テスト候補を一覧にし、一つずつ「こう動いてほしい」を実行可能な形にする。
  • Redでは失敗理由、Greenでは既存テストも含む合格、Refactorでは振る舞いを変えない整理を確認する。
  • 合格は、書いた期待結果についての証拠。仕様の妥当性や製品全体の品質は別途確認する。

赤・緑・整理の前に、確かめたい場面を並べる

Kent BeckによるCanon TDDは、テストシナリオの一覧から一つを実行可能なテストにし、コードを変えて既存分も通し、必要なら整理して繰り返す流れを説明しています。Martin Fowlerの解説も、テスト一覧と設計の整理を含めて説明しています。

すべてのテストを先に完成させるという意味ではありません。小さな問いから始め、分かったことを次の問いへ加えます。

テスト候補から一件を選び、期待する理由で失敗を確認し、実装して以前のテストも通し、必要な整理の後に次の候補を選ぶTDDの流れ
編集部作図。Redは失敗の理由まで確認します。Greenは選んだテストの合格であり、製品全体の完成を意味しません。整理する理由がなければ、そのまま次の候補へ進めます。

例の仕様は次の通りです。定員と予約済み数を受け取り、残数を返します。0を含む非負の安全な整数だけを受け付け、予約済み数が定員を超える場合は、入力の組み合わせが不正だと分かるよう例外にします。ここで安全な整数とは、JavaScriptで整数を正確に扱える範囲の値です。

表が収まらない場合は、横にスクロールできます

テスト候補入力の例期待する結果
通常の残数定員5、予約済み32
満席・定員ゼロ5と5、0と00
予約がまだない5と05
定員を超えた予約5と6例外
不正な値負数、小数、文字列、無限大など例外
整数の扱える範囲最大の安全な整数と、その一つ前差は1

実務なら「予約超過は業務上あり得るか」「待機枠をどう扱うか」を先に確認します。ここで例外を選んだのは、この小例の仕様です。業務上の答えが未定のまま、コードの都合で決めないようにします。

最初の一件を、期待した理由で失敗させる

空の作業フォルダーに、次の2ファイルを保存します。拡張子を.mjsにすると、この例のimportとexportを利用できます。Node.js 24系を想定し、掲載例は24.18.0で実行確認しました。追加のnpmパッケージは不要です。Node.js公式のテスト実行方法

まずseats.test.mjsです。

import test from 'node:test';
import assert from 'node:assert/strict';
import { remainingSeats } from './seats.mjs';

test('定員5、予約3なら残り2', () => {
  assert.equal(remainingSeats(5, 3), 2);
});

次は、まだ正しく計算しないseats.mjsです。読み込みエラーではなく、値の違いを確認するための仮実装です。

export function remainingSeats(capacity, reserved) {
  return 0;
}

同じフォルダーで実行します。

node --test seats.test.mjs

期待した値2に対して0が返り、失敗します。ファイルが見つからない、テスト件数が0、構文エラーなどの場合は、この振る舞いを検査できていません。表示された失敗理由を先に直します。

seats.mjsを次へ置き換え、もう一度同じコマンドで確かめます。

export function remainingSeats(capacity, reserved) {
  return capacity - reserved;
}

これで最初の一件は通ります。ただし、一覧にある不正入力の条件は、まだ満たしていません。最初のGreenは途中段階です。

境界と不正入力を、次のテストにする

seats.test.mjsを次の内容へ置き換えます。通常値を残したまま、満席や不正入力を追加しています。実際のTDDでは、一覧の候補を一つずつ加えて失敗と合格を確かめます。ここでは紙幅のため、追加後のファイルをまとめて掲載します。

import test from 'node:test';
import assert from 'node:assert/strict';
import { remainingSeats } from './seats.mjs';

test('定員5、予約3なら残り2', () => {
  assert.equal(remainingSeats(5, 3), 2);
});
test('満席、定員ゼロ、予約ゼロを扱う', () => {
  assert.equal(remainingSeats(5, 5), 0);
  assert.equal(remainingSeats(0, 0), 0);
  assert.equal(remainingSeats(5, 0), 5);
});
test('予約が定員を超えたら例外', () => {
  assert.throws(() => remainingSeats(5, 6), RangeError);
});
test('負数を拒否する', () => {
  assert.throws(() => remainingSeats(-1, 0), RangeError);
  assert.throws(() => remainingSeats(5, -1), RangeError);
});
test('整数ではない値を拒否する', () => {
  for (const value of [1.5, '5', NaN, Infinity]) {
    assert.throws(() => remainingSeats(value, 0), RangeError);
    assert.throws(() => remainingSeats(5, value), RangeError);
  }
});
test('安全な整数の上限で差を求める', () => {
  const max = Number.MAX_SAFE_INTEGER;
  assert.equal(remainingSeats(max, max - 1), 1);
});
test('安全な整数の範囲外を拒否する', () => {
  const tooLarge = Number.MAX_SAFE_INTEGER + 1;
  assert.throws(() => remainingSeats(tooLarge, 0), RangeError);
  assert.throws(() => remainingSeats(5, tooLarge), RangeError);
});

単純な引き算のままでは、不正な入力を拒否するテストが失敗します。seats.mjsを次へ置き換えます。

export function remainingSeats(capacity, reserved) {
  if (!Number.isSafeInteger(capacity) || capacity < 0 ||
      !Number.isSafeInteger(reserved) || reserved < 0 ||
      reserved > capacity) {
    throw new RangeError('invalid seat counts');
  }
  return capacity - reserved;
}

同じコマンドで7テストが通ることを確かめます。0席を有効な状態として扱い、入力の誤りを0に丸めて隠さないところが、この仕様の要点です。

通る状態を保ちながら、必要な整理をする

両方の引数で行っている「非負の安全な整数か」という判定に名前を付けるなら、次のようにできます。これをseats.mjsの最終版とします。

function isSeatCount(value) {
  return Number.isSafeInteger(value) && value >= 0;
}

export function remainingSeats(capacity, reserved) {
  if (!isSeatCount(capacity) || !isSeatCount(reserved) ||
      reserved > capacity) {
    throw new RangeError('invalid seat counts');
  }
  return capacity - reserved;
}

この変更は、外から見た振る舞いを保って内部を整理するリファクタリングです。前の実装が十分読みやすいなら、無理に分ける必要はありません。どちらの場合も、以前からあるテストを含めて実行します。

テスト自身も点検します。たとえば最後の引き算を一時的に足し算へ変えたとき、通常値のテストは失敗するでしょうか。失敗しないなら、対象のコードが使われていない、期待値が誤っているなどの可能性を調べます。これは検出力を確認する小さな故障注入です。確認後は元へ戻して再実行します。

この例が確かめていないこと

この関数は、与えられた2つの数から残数を求めるだけです。予約情報の保存、二人が同時に最後の席を予約した場合、予約者の権限、画面への反映は扱っていません。実サービスでは、それぞれの境界で別の検証が必要です。

表が収まらない場合は、横にスクロールできます

得られる確認その確認だけでは決められないこと
関数が期待した値・例外を返すその期待結果が実際の業務ルールに合うか
選んだ入力例でテストが通るすべての入力、連携先、実行条件で正しいか
模擬の外部サービスとやり取りできる本物のサービスの権限・制限・障害時にも動くか
コードを整理した後も合格する画面の使いやすさや、公開してよい品質を満たすか

テストの期待値を実装からコピーしたり、AIに同じ曖昧な要件から実装とテストを作らせたりすると、同じ誤解を共有することがあります。期待結果の根拠は、仕様や具体的な業務例へ戻して確認します。

一つの変更で、手戻りまで確かめる

既存コードに導入するなら、変更対象を狭く選びます。不具合なら再現条件と期待結果を用意し、そのテストが現状では失敗することを確認します。必要な既存動作も守れるようにしてから、修正と整理を進めます。全体の書き直しを開始条件にする必要はありません。

効果を見るときは、テストを書く時間だけでなく、仕様の確認、実装、レビュー、手戻りまでを含めます。対象の複雑さが違う作業を単純比較せず、発見した不具合と見逃した条件も残します。TDDの手順を守ったことだけから、開発速度や品質の改善率は決まりません。

小さなテストは、曖昧だった要求を話し合うきっかけにもなります。その検証を継続的に実行する仕組みはCI/CDの入門、何を作るかの見直しはアジャイル開発の進め方につながります。テストを増やす前に、次の一件で何が分かると判断しやすくなるかを選んでください。

よくある質問(FAQ)