テスト駆動開発(TDD)による開発ハンズオン

0.はじめに

最近見よう見まねで開発をしていますが、かなり我流のため様々な開発手法を学んでいきたいと思っています。
今回はテスト駆動開発(TDD) の触りとして、簡単なTDD開発体験をしていきます。

1.基本的な用語

1.1.テスト駆動開発(TDD)とは?

  • 以下3つの「テスト(Red)→コード(Green)→リファクタリング(Refactor)」 のサイクルを繰り返す開発手法を指す。
    具体的には、テストを先に書くことで、事前に機能を動かすために必要な入力と出力を考えることで設計の質が向上し、作成されたコードを整理して、新しい機能を追加・拡張していくための手法です。
項番 ステップ ステップ内容
1 失敗するテストを書く(Red) ユーザー要件を理解して、期待する結果のテストを作成する
2 テストをパスさせる(Green) テストが通るために必要なコードだけを書く
3 リファクタリング(Refactor) コードを整理してシンプルにする

1.2.利用ツール(pytest)について

Pythonで広く使われているテストフレームワークで、シンプルにテストを書くことが可能です。
特別な定型コードが少なく、Pythonの標準的な機能を使ってテスト関数を定義できます

1.3.assertとは?

  • Pythonの組込み文で、条件が真(True)であることを確認するためのもので、もし条件が偽(False)の場合、AssertionErrorという例外が発生する。
■基本的な構文
assert 条件式, "条件が False の場合表示される エラーメッセージ(省略可)"

■実際の利用のされ方
「result["success"] is True」部分が条件式にあたる

assert result["success"] is True, "失敗です。"
 ・result["success"] がTrue の場合:※何も出力されない
 ・result["success"] がFalseの場合:「失敗です。」と表示

2.ハンズオン

2.0.前提条件

  • Google Cloud プロジェクトが作成済みで、課金が有効になっていること
  • GitHubにアカウントが作成済みであること
  • GitHub CLI がインストール済みであること

2.1.環境のセットアップ

# プロジェクトフォルダを作成
mkdir 20250427_vending_machine_tdd && cd 20250427_vending_machine_tdd

# requirements-testファイルを作成
cat > requirements-test.txt << EOF
pytest==7.*
EOF

# テスト用ライブラリインストール
pip install -r requirements-test.txt

2.2.テストの作成

2.2.1.テストファイルの作成

  • main.pyが存在していないため失敗するが、先にテストファイルを作成。
cat > test_main.py << 'EOF'
# main.py の VendingMachineクラス を インポート
from main import VendingMachine

def test_initial_balance_is_zero():
    """自動販売機の初期残高は0円であることをテスト"""
    machine = VendingMachine()
    # 「machine.get_balance() == 0」がFalseならエラー
    assert machine.get_balance() == 0

def test_insert_coin():
    """コインを投入すると残高が増えることをテスト"""
    machine = VendingMachine()
    machine.insert_coin(100)
    # 「machine.get_balance() == 100」がFalseならエラー
    assert machine.get_balance() == 100
EOF

2.2.2.テストの実施

  • main.pyがないためエラーが出力される。
# テストの実行
pytest -v

# 出力例:レスポンス(一部抜粋:ERRORが出力される)
== short test summary info ==
ERROR test_main.py
!! Interrupted: 1 error during collection !!
== 1 error in 0.08s ==

2.3.テストをパスするためのファイル作成

2.3.1.main.py ファイルの作成

  • VendingMachineクラス0円を返却す get_balance関数コインを投入すると残高の増える insert_coin関数(現在0円) を 作成する。
# ファイルの作成
cat > main.py << EOF
# VendingMachineのクラス
class VendingMachine:
    # 初期化
    def __init__(self):
        self.balance = 0

    # 初期残高は0円を返す
    def get_balance(self):
        return self.balance

    # コインを投入すると残高が増える
    def insert_coin(self, amount):
        self.balance += amount
EOF

2.3.2.テストの実施

  • 想定していた main.py が作成されたため、レスポンスに PASSED が出力される。
# テストの実行
pytest -v

# 出力例:レスポンス(一部抜粋)
== test session starts ==
test_main.py::test_initial_balance_is_zero PASSED [ 50%]
test_main.py::test_insert_coin PASSED [100%]

== 2 passed in 0.01s ==

2.4.追加機能のためのテストを追加

2.4.1.テストファイルの作成

  • 機能を追加するためにテストを追加
# テストをファイルの作成
cat >> test_main.py << EOF

def test_get_products():
    """商品リストを取得できることをテスト"""
    machine = VendingMachine()
    products = machine.get_products()
    # isinstance組込関数(products が list型)でなければエラー
    assert isinstance(products, list)
    # len組込関数(products) が 0 より小さければエラー
    assert len(products) > 0

    # 商品の構造をチェック
    for product in products:
        # products の中に "id"がなければエラー
        assert "id" in product
        # products の中に "name"がなければエラー
        assert "name" in product
        # products の中に "price"がなければエラー
        assert "price" in product

def test_purchase_success():
    """商品を購入できることをテスト"""
    machine = VendingMachine()
    # get_products関数から商品リスト取得
    products = machine.get_products()
    # 商品リストの[0]を選択
    product = products[0]
    # 500円を入れる
    machine.insert_coin(500)
    # 商品リスト[0]の[id]を渡す
    result = machine.purchase(product["id"])
    # [購入が成功]しなければエラー
    assert result["success"] is True
    # [id]が辞書型でなければエラー
    assert result["product"] == product
    # get_balance() と 500-(商品の金額)が同じでなければエラー
    assert machine.get_balance() == 500 - product["price"]

def test_purchase_insufficient_balance():
    """残高不足の場合は購入できないことをテスト"""
    machine = VendingMachine()
    # get_products関数から商品リスト取得
    products = machine.get_products()
    # 商品リストの[0]を選択
    product = products[0]
    # 10円を入れる
    machine.insert_coin(10)
    # 商品リスト[0]の[id]を渡す
    result = machine.purchase(product["id"])
    # [購入が失敗]しなければエラー
    assert result["success"] is False
    # [残高不足です]が表示されなければエラー
    assert result["message"] == "残高不足です"
    # get_balance() と 10 が同じでなければエラー
    assert machine.get_balance() == 10
EOF

2.4.2.テストを実行する

  • main.pyに追加機能は未だないためエラーが表示される
# テスト実行
pytest -v

# 出力例:レスポンス(一部抜粋)
== short test summary info ==
FAILED test_main.py::test_get_products - AttributeError: 'VendingMachine' object has no attribute 'get_products'
FAILED test_main.py::test_purchase_success - AttributeError: 'VendingMachine' object has no attribute 'get_products'
FAILED test_main.py::test_purchase_insufficient_balance - AttributeError: 'VendingMachine' object has no attribute 'get_products'
== 3 failed, 2 passed in 0.12s ==

2.5.main.pyへ関数追加

2.5.1.ファイルへ関数追加

  • 追加機能を含めたファイルの追加
cat > main.py << EOF
# VendingMachineのクラス
class VendingMachine:
    # 初期化
    def __init__(self):
        self.balance = 0
        self.products = [
            {"id": 1, "name": "コーラ", "price": 150},
            {"id": 2, "name": "お茶", "price": 130},
            {"id": 3, "name": "水", "price": 100}
        ]

    # 初期残高は0円を返す
    def get_balance(self):
        return self.balance

    # コインを投入すると残高が増える
    def insert_coin(self, amount):
        self.balance += amount

    # 商品を返す
    def get_products(self):
        return self.products

    # 商品を購入
    def purchase(self, product_id):
        product = None
        for p in self.products:
            if p["id"] == product_id:
                product = p
                break

        if product is None:
            return {
                "success": False,
                "message": "商品が見つかりません"
            }

        # 残高チェック
        if self.balance < product["price"]:
            return {
                "success": False,
                "message": "残高不足です"
            }

        # 購入処理
        self.balance -= product["price"]
        return {
            "success": True,
            "product": product
        }
EOF

2.5.2.テスト実行

  • テストを実行して、すべてのテストが成功したことを確認
# テスト実行
pytest -v

# 出力例:レスポンス(一部抜粋)
test_main.py::test_initial_balance_is_zero PASSED [ 20%]
test_main.py::test_insert_coin PASSED [ 40%]
test_main.py::test_get_products PASSED [ 60%]
test_main.py::test_purchase_success PASSED [ 80%]
test_main.py::test_purchase_insufficient_balance PASSED [100%]

2.6.リファクタリング

  • 以下観点に従い、最終的にリファクタリングを実施
項番 主な観点 詳細
1 有効なコインの導入 valid_coinsリストを追加して、受け付けるコインの種類を制限
2 ヘルパーメソッドの追加 商品検索ロジックをget_product_by_id()として分離し、コードの再利用性を向上
3 イミュータブルな設計の導入 get_products()で配列のコピーを返し、外部から元データが変更されないように保護
4 コードの責務の明確化 各メソッドの役割を明確にし、単一責任の原則に従った設計に改善
5 エラー処理の改善 適切なエラーメッセージのレスポンス

2.6.1.リファクタリング

# VendingMachineのクラス
class VendingMachine:
    # 初期化メソッド - 必要な属性を設定
    def __init__(self):
        self.balance = 0
        # 商品データをリスト形式で定義
        self.products = [
            {"id": 1, "name": "コーラ", "price": 150},
            {"id": 2, "name": "お茶", "price": 130},
            {"id": 3, "name": "水", "price": 100}
        ]
        # 有効なコインの種類を定義(リファクタリングで追加)
        self.valid_coins = [10, 50, 100, 500]

    # 残高を取得するメソッド
    def get_balance(self):
        return self.balance

    # コインを投入するメソッド - 有効なコインかチェック機能を追加
    def insert_coin(self, amount):
        if amount in self.valid_coins:
            self.balance += amount
            return True
        return False

    # 商品リストを取得するメソッド - コピーを返すことで元データの保護
    def get_products(self):
        # 配列のコピーを返す(イミュータブル設計)
        return self.products.copy()

    # 商品IDから商品を検索するヘルパーメソッド(リファクタリングで抽出)
    def get_product_by_id(self, product_id):
        """IDから商品を取得するヘルパーメソッド"""
        for product in self.products:
            if product["id"] == product_id:
                return product
        return None

    # 商品を購入するメソッド - ヘルパーメソッドを活用
    def purchase(self, product_id):
        # 商品検索をヘルパーメソッドに委譲
        product = self.get_product_by_id(product_id)

        # 商品が存在しない場合のエラー処理
        if product is None:
            return {
                "success": False,
                "message": "商品が見つかりません"
            }

        # 残高不足の場合のエラー処理
        if self.balance < product["price"]:
            return {
                "success": False,
                "message": "残高不足です"
            }

        # 購入処理 - 残高から商品価格を引く
        self.balance -= product["price"]

        # 成功レスポンスを返す
        return {
            "success": True,
            "product": product
        }

2.6.2.テスト実行

  • リファクタリング後も問題なくテストが成功することを確認
# テスト実行
pytest -v

# 出力例:レスポンス(一部抜粋)
test_main.py::test_initial_balance_is_zero PASSED [ 20%]
test_main.py::test_insert_coin PASSED [ 40%]
test_main.py::test_get_products PASSED [ 60%]
test_main.py::test_purchase_success PASSED [ 80%]
test_main.py::test_purchase_insufficient_balance PASSED [100%]

3.おわりに

3.1.得られた知見

  • テストを先に書くことで設計が明確になる
  • 小さな機能ごとに開発サイクルを回すことで品質が向上する

3.2.今後の課題

  • フレームワークを利用した開発に関してのTDD開発の体験
Last modified: 2025-04-28

Author