はじめに
はじめまして、私はエンジニア2年目の駆け出しプログラマーです。私がTDDと出会ったのは半年ほど前、上司に「今回はTDDでやってみよう」と言われたことがきっかけでした。最初は戸惑いましたが、徐々にその効果を実感し、Kent Beck著「テスト駆動開発」を読み込むことで理解を深めていきました。
今月、社内発表会でTDDについてプレゼンする機会があり、その準備の過程で得た知見や実践上の工夫をまとめることにしました。この記事は「TDDをやってみたいけど、どう始めればいいのかわからない」という方に特に読んでいただきたいと思っています。
なぜTDDを導入するのか?
「なぜわざわざテストを先に書くのか?」——これは、私がTDDを知ったときに最初に抱いた疑問でした。
しかし、TDDには以下のようなメリットがあります:
TDDの効果
-
バグの早期発見
- 機能実装直後にテストを通すことで、問題を早期に発見できます。
- Microsoft社の研究によると、リリース前の製品の欠陥密度がTDDを使用した場合、40~90%減少したというデータがあります。[^1]
-
高品質なコードの生成
- テストを意識して設計することで、関数やクラスごとの役割が明確になり、それぞれが他に強く依存しない、まとまりのあるコードになりやすくなります。
- IEEE国際シンポジウムで発表された研究結果では、TDDを使ったコードは、非TDDで開発されたソフトウェアよりも品質が高いと報告されています。[^2]
-
シンプルな設計の促進
- 「必要なものだけを実装する」習慣が身につき、過度な設計を避けられます。
- ノースカロライナ州立大学のコンピューターサイエンス学科による実験結果では、**79%の開発者が「TDDがシンプルな設計を促進する」**と回答しました。[^3]
私が現在携わっているプロジェクトでは、すでにTDDが導入されており、バグ修正や機能追加がスムーズに行われている実感があります。
TDDの基本サイクル
TDDの基本は「Red → Green → Refactor」のサイクルです。 このシンプルなサイクルを回すことで、品質の高いコードを段階的に作り上げていきます。
Red:期待する動作を確認するためのテストを書き、実行する
テストを先に書くことで、「何を実装するか」を明確にします。この段階のテストは必ず失敗します。
ポイント:
この段階で重要なのは、テストが失敗していることを確認するだけでなく、実装が期待通りに動作していないという事実に注目することです。
Green:テストが通るように実装を行う
最も単純な方法でテストが通ることを目指します。
ポイント:
機能がテスト通りに動作しているかを確認するのが目的で、最適化や整理は行わず、テストが通ることを最優先します。
Refactor:テストが通ることを確認しつつ、実装を改善する
コードの重複を取り除き、より良い設計にしていきます。
ポイント:
改善後も機能が壊れていないことを確認するため、すべてのテストが再度通ることを確認します。
実際の進め方:サンプルコード
以下に、TDDの具体的な進め方を簡単な例で示します。
シナリオ
名前を渡すと、挨拶を返してくれる関数 greet()
を作ります。
ゴール
-
greet("Taro")
→"Hello, Taro"
-
greet("Hanako")
→"Hello, Hanako"
のように、名前に応じた挨拶メッセージを返す関数をTDDで作ることが目標です。
ステップ1:RED(テストを書く)
まず最初に、greet("Taro")
を呼んだら "Hello, Taro"
を返すことを確認するテストを書きます。
# test_greet.py
import unittest
from greet import greet
class TestGreet(unittest.TestCase):
def test_greet_returns_hello_taro(self):
self.assertEqual(greet("Taro"), "Hello, Taro")
if __name__ == '__main__':
unittest.main()
この時点では greet
関数が定義されていないので、テスト実行すると失敗します。
$ python test_greet.py
ModuleNotFoundError: No module named 'greet'
ステップ2:GREEN(テストが通るようにする)
テストが通るようにエラーを解決する必要があるので、まずは greet
関数を定義します。
# greet.py
def greet(name):
pass
ModuleNotFoundError
は解決したものの、中身が空のため、テストを実行すると今度は None
が返されてテストが失敗します。
======================================================================
FAIL: test_greet_returns_hello_name (__main__.TestGreet.test_greet_returns_hello_name)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_greet.py", line 6, in test_greet_returns_hello_name
self.assertEqual(greet("Taro"), "Hello, Taro")
AssertionError: None != 'Hello, Taro'
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
テストが通るように、引き続きエラーを解決します。
# greet.py
def greet(name):
return "Hello, Taro"
ポイント:
**GREENの段階では「まずテストが通るようにする」ことを最優先にするため、あえてハードコードで実装しています。**一見非効率に見えるかもしれませんが、この小さなステップを踏むことで、テストと実装の間の整合性を確実に保ちながら進められます。
これでテストが通りました。
$ python test_greet.py
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
ステップ3:REFACTOR(必要なら改善)
今回はまだ1ケースだけなのでこのままでもOKですが、次のステップへ進みます。
ステップ4:RED(2つ目のテストを書く)
Hanako
に対応するテストケースを追加します。
# test_greet.py
class TestGreet(unittest.TestCase):
def test_greet_returns_hello_taro(self):
self.assertEqual(greet("Taro"), "Hello, Taro")
# 追加されたテストケース
def test_greet_returns_hello_hanako(self):
self.assertEqual(greet("Hanako"), "Hello, Hanako")
Hanako
に対する処理が未対応のため、テストは失敗します。
======================================================================
FAIL: test_greet_returns_hello_hanako (__main__.TestGreet.test_greet_returns_hello_hanako)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_greet.py", line 9, in test_greet_returns_hello_hanako
self.assertEqual(greet("Hanako"), "Hello, Hanako")
AssertionError: 'Hello, Taro' != 'Hello, Hanako'
- Hello, Taro
? ^ ^
+ Hello, Hanako
? ^ ^^^
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
ステップ5:GREEN(再び通るようにする)
条件分岐で Hanako
に対応するように変更します。
# greet.py
def greet(name):
if name == "Taro":
return "Hello, Taro"
elif name == "Hanako":
return "Hello, Hanako"
テストが通ることを確認します。
$ python test_greet.py
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
ステップ6:REFACTOR(改善)
ここで重複をなくして汎用化できるコードに書き直します。
# greet.py
def greet(name):
return f"Hello, {name}"
このリファクタによって実装が壊れていないことをテストで確認します。
$ python test_greet.py
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
これがTDDの基本サイクルです。小さなステップを繰り返しながら、確実に動作する高品質なコードを構築していきます。
実務で直面した課題と書籍で学んだ解決策
私がTDDを実際の業務で実践する中で直面した課題と、それらに対する解決策を紹介します。
1. テスト項目の整理:TODOリスト
課題
- どこからテストを書き始めればいいかわからない
- 必要なテスト項目を整理せずに実装を始めてしまうと、途中で方針が曖昧になる
解決策
TDDの進め方として「TODOリスト」を作成します。実装すべき機能のテスト項目を箇条書きにすることで、テストの意図を明確にできます。
例:パスワードバリデーション
パスワードバリデーションのTODOリスト
パスワードが8文字未満の場合にエラーが発生する
[ ] ステータスコード400が返される
[ ] エラーメッセージに”Password must be at least 8 characters long”が含まれる
パスワードが8文字以上の場合にエラーが発生しない
[ ] ステータスコード200が返される
[ ] パスワードに関するエラーメッセージが含まれない
パスワードに特殊文字が含まれていない場合にエラーが発生する
[ ] ステータスコード400が返される
[ ] エラーメッセージに”Password must contain at least one special character”が含まれる
このようなリストを先に作成しておくことで、「次に何をテストすべきか」が明確になり、迷いが減ります。Kent Beckの「テスト駆動開発」でも、TODOリストの作成は重要なプラクティスとして紹介されています。
2. テストメソッドの命名規約
課題
- テストメソッドの名前が長すぎたり、アサート内容をそのまま書いてしまうと、実装変更に引きずられるリスクが高まる
- 一方で、曖昧すぎるテスト名は目的が見えなくなる
解決策
実装の詳細には踏み込まず、テスト対象の「振る舞い」が分かる名前にします。
例
改善前:
def test_do_validation_with_input_string_and_check_error_message_is_correct_for_short_password():
assert validate_password("1234567") == "エラー: パスワードは8文字以上"
問題点:
- 何をしたいのかは命名からはまったく伝わってこない
- 無駄に長い
改善後:
def test_Return_validation_error_when_password_too_short:
assert validate_password("1234567") == "エラー: パスワードは8文字以上"
改善点:
「パスワードが短すぎたら、バリデーションエラーになる」という目的だけに焦点を当てています。
3. 1テストメソッド1アサート
課題
1つのテストメソッド内で複数のアサーションを実行すると、エラーが発生したときに原因が特定しにくくなります。
解決策
1つのテストメソッドでは1つのアサートのみを記述することで、どの条件でテストが失敗したのかを明確にします。
例
改善前:
def test_validation_for_user_registration():
assert validate_username("") == "エラー: ユーザー名が空です"
assert validate_email("invalid_email") == "エラー: メールアドレスが不正です"
assert validate_password("123") == "エラー: パスワードは8文字以上"
改善後:
def test_username_validation_when_empty():
assert validate_username("") == "エラー: ユーザー名が空です"
def test_email_validation_when_invalid_format():
assert validate_email("invalid_email") == "エラー: メールアドレスが不正です"
def test_password_validation_when_too_short():
assert validate_password("123") == "エラー: パスワードは8文字以上"
メリット:
- テストが失敗したときに原因を特定しやすい
- テストコードの可読性が向上する
4. モックオブジェクトの使用
課題
外部コマンド(useradd
)を呼び出す関数のテストを行いたいが、実際にシステムにユーザーが作成されてしまうという問題があります。
解決策
subprocess.run
を Mock することで、外部コマンドを実行せず、結果だけを疑似的に返すことができます。
例
改善前(Mockなし):
# user_manager.py
import subprocess
def create_user(username):
result = subprocess.run(['useradd', username], capture_output=True, text=True)
return result.returncode == 0
# test_user_manager_real.py
from user_manager import create_user
def test_create_user():
assert create_user("testuser") # ⚠️ 実際にユーザーが作成される!
問題点:
ユーザーが実際に作成され、システムに影響を及ぼす可能性があります。
改善後(Mock使用):
## test_user_manager_mock.py
from user_manager import create_user
from unittest.mock import patch, MagicMock
def test_create_user_mocked():
mock_result = MagicMock()
mock_result.returncode = 0
with patch('subprocess.run', return_value=mock_result) as mock_run:
assert create_user("testuser")
mock_run.assert_called_with(['useradd', 'testuser'], capture_output=True, text=True)
このように外部コマンドを実行せずに結果だけ検証できるため、安全かつ高速にテストを実行できます。
明日から始められるTDD実践ステップ
-
まずは観察から
- 普段の開発で「後からテストを書く」のがどれだけ大変かを記録する
- バグ修正時に「このテストがあれば防げたかも」と思う項目をメモする
-
小さな一歩を踏み出す
- 次の小さなタスクで「先にテストを1つ書いてから実装する」を試す
- TODOリストを先に作成し、テスト項目を整理してから取り掛かる
-
自分用のチェックリスト作成
- Red-Green-Refactorのサイクルを守れているか確認するチェックリスト
- 「テストを実行して失敗を確認したか」「最小限の実装で済ませたか」など
-
既存コードへのアプローチ
- 既存コードを修正する前に、その動作を確認するテストを先に書く
- バグ修正では「バグを再現するテスト」から始める習慣をつける
-
振り返りの習慣化
- 1日の終わりに5分間、TDDで良かった点・困った点をメモする
- 週末に振り返り、次週の改善点を決める
まとめ
TDDのメリット
- バグの発見が早くなり、デバッグ時間が減少する
- コードの品質が向上し、リファクタリングが容易になる
- テストコードが自然とドキュメントの役割を果たす
TDDのデメリットと導入の難しさ
- 初期の学習コストが高い(慣れるまでの時間が必要)
- 短期的には開発速度が落ちるように感じる(長期的には向上する)
- レガシーコードへの適用には追加の工夫が必要
TDDのゴール
TDDの目標は「動作するきれいなコード」です。テストを書くことが目的ではなく、より良いソフトウェアを作るための手段です。
まずは小さなステップから始めて、徐々にTDDの恩恵を感じていきましょう。
参考文献
テスト駆動開発(TDD)の利点に関する統計と研究(翻訳)
https://techracho.bpsinc.jp/hachi8833/2021_01_06/100858
[^1]:https://www.microsoft.com/en-us/research/wp-content/uploads/2009/10/Realizing-Quality-Improvement-Through-Test-Driven-Development-Results-and-Experiences-of-Four-Industrial-Teams-nagappan_tdd.pdf
[^2]: https://arxiv.org/ftp/arxiv/papers/1711/1711.05082.pdf#:~:text=Their results show that the,level in projects employing TDD
[^3]:https://proceedings.informingscience.org/InSITE2012/InSITE12p165-187Bulajic0052.pdf