0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TDDの基礎から学ぶ:テスト駆動開発の実践ガイド

0
Posted at

はじめに

みなさん、テストって書いていますか?実装が終わってから「さて、テストでも書くか...」と重い腰を上げている方も多いのではないでしょうか。

実は、テストを先に書くTDD(Test-Driven Development:テスト駆動開発)という開発手法があります。最初は「えっ、テストを先に?」と思うかもしれませんが、この手法には多くのメリットがあり、世界中の開発者に支持されています。

この記事では、TDDの基礎から実践的な使い方まで、できるだけわかりやすく解説していきます。ぜひ最後までお付き合いください!

TDDとは何か

TDD(Test-Driven Development)は、文字通りテストが開発を駆動するという開発手法です。

通常の開発では、以下のような流れですよね:

  1. 機能を実装する
  2. 動作確認をする
  3. テストを書く(時間があれば...)

しかしTDDでは、この順序を根本的に変えます:

  1. まず失敗するテストを書く
  2. テストが通る最小限のコードを書く
  3. コードをリファクタリングする

最初は逆説的に感じるかもしれませんが、この「テストファースト」のアプローチが、コードの品質や設計に大きな影響を与えるのです。

TDDの基本サイクル:Red-Green-Refactor

TDDの中核となるのが、Red-Green-Refactor(レッド・グリーン・リファクタ)と呼ばれる3つのステップです。信号機の色を思い浮かべると覚えやすいですね。

🔴 Red(レッド):失敗するテストを書く

最初のステップは、まだ実装されていない機能のテストを書くことです。

当然、実装がないのでテストは失敗します。テストランナーが赤く表示されることから「Red」と呼ばれています。

// 例:まだ存在しない add 関数のテスト
test('1 + 2 は 3 を返す', () => {
  expect(add(1, 2)).toBe(3);
});

このステップで大切なのは、「何を実装するか」を明確にすることです。テストを書くことで、仕様を具体化していくのですね。

なぜ最初に失敗させる必要があるの?

実は、これにはちゃんと理由があります:

  • テスト自体が正しく動くことを確認する:最初から成功するテストでは、本当にテストが機能しているか分かりません
  • 実装すべき機能を明確にする:テストを書くことで、インターフェースや期待する動作が明確になります
  • モチベーション向上:赤から緑に変わる瞬間は、ちょっとした達成感があります

🟢 Green(グリーン):テストを通す最小限のコードを書く

次のステップは、テストを通すための最小限のコードを書くことです。

ここで重要なのは「最小限」という部分。完璧な実装を目指すのではなく、まずはテストが通ることだけを目指します。

// テストを通す最小限の実装
function add(a, b) {
  return a + b;
}

時には、こんな極端な実装でも良いのです:

// 極端な例:ハードコードでも最初はOK
function add(a, b) {
  return 3; // とりあえず最初のテストだけ通す
}

「えっ、こんなのダメでしょ!」と思うかもしれませんが、まずはテストを緑にすることが目標です。次のテストケースを追加すれば、自然とより汎用的な実装に進化していきます。

Greenステップのコツ

  • 完璧を求めない:まずは動くコードを書く
  • シンプルに保つ:複雑な実装は後のリファクタで
  • 一つずつ確実に:一度に複数の機能を実装しない

🔵 Refactor(リファクタ):コードを改善する

最後のステップは、テストが通った状態を保ちながらコードを改善することです。

リファクタリングの段階では、以下のような改善を行います:

  • 重複コードの削除
  • わかりやすい変数名への変更
  • 処理の分割や統合
  • パフォーマンスの最適化
// リファクタリング前
function calculate(x, y, op) {
  if (op === 'add') {
    return x + y;
  } else if (op === 'subtract') {
    return x - y;
  }
}

// リファクタリング後
function calculate(x, y, operation) {
  const operations = {
    add: (a, b) => a + b,
    subtract: (a, b) => a - b,
  };
  
  return operations[operation](x, y);
}

重要なのは、リファクタリング中もテストは常に緑を保つことです。テストがあるからこそ、安心してコードを改善できるのですね。

サイクルの繰り返し

Red-Green-Refactorは、機能を実装するたびに繰り返します:

Red → Green → Refactor → Red → Green → Refactor → ...

このリズムに慣れてくると、小刻みかつ確実に開発を進められるようになります。

TDDのメリット

「何でわざわざテストを先に書く必要があるの?」と疑問に思う方もいるかもしれません。ここでは、TDDがもたらす具体的なメリットを見ていきましょう。

1. バグの早期発見

テストを先に書くことで、実装と同時にバグを発見できます。

従来の方法では、実装→テスト作成→バグ発見→修正という流れでしたが、TDDではこのサイクルがはるかに短くなります。バグを見つけるのが早ければ早いほど、修正コストは低くなります。

2. 設計の改善

テストを書くためには、「どう使われるか」を考える必要があります。これが自然と良い設計につながります。

// テストを書こうとすると...
test('ユーザーの年齢を取得する', () => {
  const user = new User({ birthDate: '1990-01-01' });
  expect(user.getAge()).toBe(34); // 2024年時点
});

// 依存関係が見えてくる
// → 現在日時に依存している!
// → テストしやすいように設計を見直そう

テストしにくいコードは、たいてい設計に問題があります。TDDは設計の問題を早期に気づかせてくれるのです。

3. ドキュメントとしての役割

よく書かれたテストは、コードの使い方を示す生きたドキュメントになります。

describe('ShoppingCart', () => {
  test('商品を追加できる', () => {
    const cart = new ShoppingCart();
    cart.addItem({ id: 1, name: '商品A', price: 1000 });
    expect(cart.getItemCount()).toBe(1);
  });

  test('合計金額を計算できる', () => {
    const cart = new ShoppingCart();
    cart.addItem({ id: 1, name: '商品A', price: 1000 });
    cart.addItem({ id: 2, name: '商品B', price: 2000 });
    expect(cart.getTotalPrice()).toBe(3000);
  });
});

このテストを読めば、ShoppingCartクラスの使い方がすぐに理解できますね。コメントと違って、テストは実際に実行されるので、常に最新の状態が保たれます。

4. リファクタリングの安心感

充実したテストがあれば、コードを大胆に改善できます。

「この部分を変えたら、他が壊れないかな...」という不安がテストによって解消されるため、積極的にコードの品質を向上させられます。

5. 実装の過不足を防ぐ

TDDでは「テストを通すために必要なコードだけ」を書きます。

これにより、使われもしない機能を実装するYAGNI(You Aren't Gonna Need It:それは必要にならない)の原則を自然と守れます。

6. デバッグ時間の削減

問題が発生したとき、どのテストが失敗しているかを見れば、問題の箇所をすぐに特定できます。

コードの至る所にconsole.logを仕込んでデバッグする時間が大幅に減ります。

TDDの実践例

それでは、具体的な例でTDDの流れを見ていきましょう。シンプルな「TODO管理クラス」を作ってみます。

要件

  • TODOアイテムを追加できる
  • TODOアイテムを完了にできる
  • 未完了のTODOの数を取得できる

ステップ1:最初のテスト(Red)

まずは「TODOを追加できる」というテストから:

describe('TodoList', () => {
  test('TODOを追加できる', () => {
    const todoList = new TodoList();
    todoList.add('牛乳を買う');
    
    expect(todoList.getAll()).toHaveLength(1);
    expect(todoList.getAll()[0].text).toBe('牛乳を買う');
  });
});

実行すると、当然エラーになります:TodoList is not defined

ステップ2:テストを通す(Green)

最小限の実装でテストを通します:

class TodoList {
  constructor() {
    this.todos = [];
  }

  add(text) {
    this.todos.push({ text });
  }

  getAll() {
    return this.todos;
  }
}

テストが通りました!🎉

ステップ3:リファクタリング(Refactor)

今のところシンプルなので、リファクタは不要です。次のテストに進みましょう。

ステップ4:次のテスト(Red)

「TODOを完了にできる」機能のテストを追加:

test('TODOを完了にできる', () => {
  const todoList = new TodoList();
  todoList.add('牛乳を買う');
  
  todoList.complete(0); // 0番目のTODOを完了にする
  
  expect(todoList.getAll()[0].completed).toBe(true);
});

実行すると失敗します:complete is not a function

ステップ5:実装(Green)

class TodoList {
  constructor() {
    this.todos = [];
  }

  add(text) {
    this.todos.push({ 
      text,
      completed: false // completedフラグを追加
    });
  }

  complete(index) {
    this.todos[index].completed = true;
  }

  getAll() {
    return this.todos;
  }
}

テスト通過!

ステップ6:さらにテスト追加(Red)

「未完了のTODOの数を取得」のテストを追加:

test('未完了のTODOの数を取得できる', () => {
  const todoList = new TodoList();
  todoList.add('牛乳を買う');
  todoList.add('掃除をする');
  todoList.add('メールを送る');
  
  todoList.complete(0);
  
  expect(todoList.getIncompleteCount()).toBe(2);
});

ステップ7:実装(Green)

class TodoList {
  // ... 既存のメソッド

  getIncompleteCount() {
    return this.todos.filter(todo => !todo.completed).length;
  }
}

ステップ8:リファクタリング(Refactor)

すべての機能が実装できたので、コードを見直します:

class TodoList {
  constructor() {
    this.todos = [];
  }

  add(text) {
    const todo = {
      text,
      completed: false,
      createdAt: new Date(), // 作成日時を追加
    };
    this.todos.push(todo);
  }

  complete(index) {
    this._validateIndex(index);
    this.todos[index].completed = true;
  }

  getAll() {
    return [...this.todos]; // 直接参照を返さないようにする
  }

  getIncompleteCount() {
    return this.todos.filter(todo => !todo.completed).length;
  }

  // プライベートメソッド
  _validateIndex(index) {
    if (index < 0 || index >= this.todos.length) {
      throw new Error('Invalid index');
    }
  }
}

すべてのテストが通ったまま、コードの品質が向上しました!

TDDのベストプラクティス

TDDをより効果的に実践するためのポイントをまとめます。

1. テストは小さく、具体的に

一つのテストでは、一つのことだけを検証しましょう。

// ❌ 悪い例:複数のことをテストしている
test('ユーザー機能', () => {
  const user = new User('太郎');
  expect(user.name).toBe('太郎');
  expect(user.getGreeting()).toBe('こんにちは、太郎さん');
  expect(user.age).toBe(0);
});

// ✅ 良い例:テストを分割
test('ユーザー名を設定できる', () => {
  const user = new User('太郎');
  expect(user.name).toBe('太郎');
});

test('挨拶文を生成できる', () => {
  const user = new User('太郎');
  expect(user.getGreeting()).toBe('こんにちは、太郎さん');
});

2. テストは読みやすく

テストは仕様書でもあるので、わかりやすく書きましょう。

AAA (Arrange-Act-Assert) パターンを使うと良いでしょう:

test('割引価格を計算できる', () => {
  // Arrange(準備)
  const product = new Product('商品A', 1000);
  const discount = 0.2; // 20%オフ

  // Act(実行)
  const discountedPrice = product.applyDiscount(discount);

  // Assert(検証)
  expect(discountedPrice).toBe(800);
});

3. 境界値をテストする

通常のケースだけでなく、エッジケースもテストしましょう:

describe('divide', () => {
  test('通常の割り算', () => {
    expect(divide(10, 2)).toBe(5);
  });

  test('小数点の割り算', () => {
    expect(divide(10, 3)).toBeCloseTo(3.33, 2);
  });

  test('0で割る場合はエラー', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });

  test('負の数の割り算', () => {
    expect(divide(-10, 2)).toBe(-5);
  });
});

4. テストの独立性を保つ

各テストは他のテストに依存しないようにしましょう。

// ❌ 悪い例:テストが相互に依存している
let cart;

test('カートを作成する', () => {
  cart = new ShoppingCart();
  expect(cart).toBeDefined();
});

test('商品を追加する', () => {
  // 前のテストに依存している!
  cart.addItem({ id: 1, price: 100 });
  expect(cart.getItemCount()).toBe(1);
});

// ✅ 良い例:各テストが独立している
test('カートを作成できる', () => {
  const cart = new ShoppingCart();
  expect(cart).toBeDefined();
});

test('カートに商品を追加できる', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, price: 100 });
  expect(cart.getItemCount()).toBe(1);
});

5. テストファーストを習慣化する

最初は違和感があるかもしれませんが、「コードを書く前にテストを書く」を徹底しましょう。

慣れてくると、この習慣が自然になり、コードの品質が格段に向上します。

6. ステップを小さく保つ

一度に大きな機能を実装しようとせず、小さなステップで進めましょう。

大きな機能 → 小さな機能に分割 → 各機能をTDDで実装

7. リファクタリングを恐れない

テストがあるからこそ、大胆にコードを改善できます。緑を保ちながら、常により良いコードを目指しましょう。

TDDの課題と対処法

TDDは素晴らしい手法ですが、万能ではありません。よくある課題と対処法を見ていきましょう。

課題1:最初は時間がかかる

対処法

  • 小さな機能から始める
  • ペアプログラミングで学ぶ
  • 慣れてくると、トータルの開発時間は短縮される

課題2:UI部分のテストが難しい

対処法

  • ビジネスロジックとUIを分離する
  • E2Eテストツール(PlaywrightやCypressなど)を活用
  • すべてをTDDで書く必要はない

課題3:既存コードへの適用が困難

対処法

  • 新機能からTDDを導入する
  • 既存コードは少しずつテストを追加
  • レガシーコード改善のテクニックを学ぶ

課題4:過度なテストによる硬直化

対処法

  • 実装の詳細ではなく、振る舞いをテストする
  • テストも定期的にリファクタリングする
  • 不要になったテストは削除する

まとめ

TDD(テスト駆動開発)は、以下の3つのステップを繰り返す開発手法です:

  1. Red:失敗するテストを書く
  2. Green:テストを通す最小限のコードを書く
  3. Refactor:コードを改善する

TDDのメリット

  • バグの早期発見
  • 設計の改善
  • 生きたドキュメント
  • リファクタリングの安心感
  • 過剰実装の防止
  • デバッグ時間の削減

始めるためのヒント

  1. 小さく始める:簡単な関数やクラスから練習
  2. 完璧を求めない:最初はぎこちなくて当然
  3. 継続する:習慣化すれば自然になる
  4. 楽しむ:緑になる瞬間を楽しもう!

TDDは最初は不自然に感じるかもしれません。でも、慣れてくると「テストなしでコードを書くのが怖い」と感じるようになります。

まずは小さな機能で試してみてください。きっと、コーディングの新しい楽しさに気づくはずです。

Happy Testing! 🧪✨

参考資料

  • 『テスト駆動開発』Kent Beck著

この記事が少しでも参考になれば嬉しいです。TDDについての質問やフィードバックがあれば、ぜひコメントで教えてください!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?