アプリケーションのコードには、「フォロワーが1万人を超えたらボーナス」「スコアが100点を下回ったら警告」のように、特定の数値(しきい値)を条件分岐に使うロジックが頻繁に登場します。では、このようなロジックをテストする際、私たちは本当に1万1人のフォロワーや、101点のスコアといった大量のテストデータを用意する必要があるのでしょうか?
答えは明確に**「いいえ」です。そのようなテストは実行が非常に遅く、非現実的です。この問題は、コードにハードコードされた「マジックナンバー」**が、テストを困難にしていることに起因します。
今回は、このようなマジックナンバーに依存するコードを、テスト容易性の観点からリファクタリングする2つの強力なアプローチ、「パラメータ化」と「モック」を紹介します。
問題点:テストが非現実的になる「マジックナンバー」
あるECサイトで、「累計購入回数が100回を超えた顧客をVIPとして認定する」という機能を考えてみましょう。
悪い例:しきい値 100
がハードコードされている
# services.py
from .models import Customer, Purchase
def check_if_customer_is_vip(customer_id: int) -> bool:
"""顧客がVIPステータスかどうかを判定する。"""
# "100" というマジックナンバーがコードに埋め込まれている
purchase_count = Purchase.objects.filter(customer_id=customer_id).count()
if purchase_count > 100:
return True
else:
return False
この関数の> 100
というロジックをテストするためには、律儀に考えると101件のPurchase
オブジェクトを作成する必要があります。これは非常に非効率で、テストスイート全体の実行速度を著しく低下させます。我々が本当にテストしたいのは、「100」という数字そのものではなく、**「しきい値を超えているかどうか」**という比較のロジックのはずです。
解決策①:しきい値を「パラメータ化」して分離する
最初の解決策は、マジックナンバーを関数の外部から注入できるように、引数として切り出す(パラメータ化する)ことです。
リファクタリング後のコード:
# services.py
def check_if_customer_is_vip(customer_id: int, vip_threshold: int = 100) -> bool:
"""顧客がVIPステータスかどうかを判定する。しきい値は引数で指定可能。"""
purchase_count = Purchase.objects.filter(customer_id=customer_id).count()
return purchase_count > vip_threshold
しきい値にvip_threshold
という名前が与えられ、デフォルト引数として 100
を持つようになりました。これにより、関数の通常の挙動は変わらないまま、テスト時だけこの値を自由に変更できます。
効率的なテストコード:
# tests/test_services.py
from .factories import CustomerFactory, PurchaseFactory
def test_is_vip_customer_with_parameter():
customer = CustomerFactory()
# テストに必要な最小限のデータ(2件)だけを作成
PurchaseFactory.create_batch(2, customer=customer)
# テストの時だけ、しきい値を「1」に設定してロジックを検証
assert check_if_customer_is_vip(customer.id, vip_threshold=1) is True
# しきい値「2」ではfalseになることも検証
assert check_if_customer_is_vip(customer.id, vip_threshold=2) is False
わずか2件のテストデータで、>
という比較ロジックが正しく機能することを迅速にテストできました。
解決策②:定数を「モック」して差し替える
もう一つの強力なアプローチは、マジックナンバーをモジュールレベルの定数として定義し、テスト時にその定数の値を一時的に書き換える(モックする)ことです。この方法は、関数のインターフェース(引数)を変更したくない場合に特に有効です。
リファクタリング後のコード:
# services.py
# しきい値をモジュール定数として定義
VIP_PURCHASE_THRESHOLD = 100
def check_if_customer_is_vip(customer_id: int) -> bool:
"""顧客がVIPステータスかどうかを判定する。"""
purchase_count = Purchase.objects.filter(customer_id=customer_id).count()
return purchase_count > VIP_PURCHASE_THRESHOLD
モックを使った効率的なテストコード:
# tests/test_services.py
from unittest import mock
def test_is_vip_customer_with_mock():
customer = CustomerFactory()
PurchaseFactory.create_batch(2, customer=customer)
# `with`ブロックの間だけ、定数'VIP_PURCHASE_THRESHOLD'が'1'になる
with mock.patch("services.VIP_PURCHASE_THRESHOLD", new=1):
assert check_if_customer_is_vip(customer.id) is True
# 'with'ブロックを抜けると、定数は元の'100'に戻っている
assert check_if_customer_is_vip(customer.id) is False
mock.patch
を使うことで、テストの期間中だけ定数を安全に差し替え、ロジックを検証できます。
どちらのアプローチを選ぶべきか?
- パラメータ化: 関数をより柔軟で再利用可能にしたい場合に最適。設計としてよりクリーンと見なされることが多い。
- モック: 関数のシグネチャを変更したくない、あるいは定数がアプリケーション全体で固定されており、変更されるべきでない場合に強力。
まとめ
コード内に存在する巨大なマジックナンバーは、テストの効率性を著しく妨げる「コードの匂い」です。
- テストは「ロジック」を検証するものであり、巨大な「データ」を検証するものではない。
- テストを困難にするマジックナンバーは、「パラメータ化」や「モック」を用いて、テスト実行時には小さな値に置き換える。
大量のテストデータ作成が必要だと感じたら、それはコードの設計を見直す良い機会です。テストのしやすさを追求することで、結果的により柔軟で堅牢な実装へと繋がっていきます。