ユニットテストの目的は、コードが正しく動作することを確認するだけではありません。テストが失敗したときに、**「何が、どのように、なぜ間違っているのか」**を迅速かつ明確に開発者に伝えることも、同様に重要な役割です。
この「診断能力」の高いテストを書くための指針として、**「1テストメソッドでは、1つの論理的な関心事のみを検証する」**という原則があります。しばしば「1テスト1アサーション」とも呼ばれるこの原則に従うことで、テストは遥かに価値のあるものになります。今回は、この原則の重要性を、具体的なリファクタリングを通して見ていきましょう。
問題点:多くの責務を抱えたテストメソッド
ユーザー名が有効かどうかを検証するis_valid_username
関数をテストするシナリオを考えます。有効なユーザー名は「4文字以上、20文字以下」とします。
validators.py
def is_valid_username(username: str) -> bool:
"""ユーザー名が4文字以上、20文字以下であればTrueを返す。"""
return 4 <= len(username) <= 20
この関数に対し、正常系・異常系すべての検証を一つのテストメソッドに詰め込むと、次のようになります。
悪い例:一つのテストに複数の関心事が混在
# tests/test_validators.py
from validators import is_valid_username
def test_is_valid_username():
# 正常系(境界値、中間値)
assert is_valid_username("abcd") # 4文字
assert is_valid_username("a" * 12) # 12文字
assert is_valid_username("a" * 20) # 20文字
# 異常系(短すぎる)
assert not is_valid_username("abc") # 3文字
# 異常系(長すぎる)
assert not is_valid_username("a" * 21) # 21文字
このテストには、主に2つの問題があります。
- 曖昧な失敗レポート: もしこのテストが失敗した場合、テストランナーは単に
test_is_valid_username
が失敗したことしか教えてくれません。「短すぎた」のか「長すぎた」のか、あるいは「正常なはずのものが弾かれた」のか、失敗の具体的な原因を特定するために、コードの内部まで見に行く必要があります。 - エラーのマスキング: テストは、アサーションが一つでも失敗した時点で実行を停止します。例えば、
assert not is_valid_username("abc")
で失敗した場合、その後の「長すぎる」ケースの検証は実行されません。一つのバグを修正したら、再実行して初めて次のバグに気づく、という非効率なサイクルに陥ります。
解決策①:振る舞いごとにテストメソッドを分割する
この問題を解決する最も基本的な方法は、検証したい「振る舞い」や「仕様」ごとにテストメソッドを分割することです。
良い例:振る舞いに基づいてテストを分割
def test_username_is_valid_with_boundary_and_normal_lengths():
"""正常系の長さ(4文字、20文字、中間)でTrueを返すことを確認する。"""
assert is_valid_username("abcd")
assert is_valid_username("a" * 12)
assert is_valid_username("a" * 20)
def test_username_is_invalid_when_too_short():
"""仕様より短いユーザー名でFalseを返すことを確認する。"""
assert not is_valid_username("abc")
def test_username_is_invalid_when_too_long():
"""仕様より長いユーザー名でFalseを返すことを確認する。"""
assert not is_valid_username("a" * 21)
このリファクタリングにより、テストの失敗レポートは劇的に改善されます。例えば、test_username_is_invalid_when_too_short
が失敗すれば、テスト名だけで「短すぎるケースの検証で問題が発生した」ことが一目瞭然です。テストが、それ自体で仕様書のように振る舞い始めます。
解決策②(モダンなアプローチ):pytest.mark.parametrize
でケースをまとめる
振る舞いごとにテストを分けるのは良いアプローチですが、似たようなテストケース(例えば、正常系の3パターン)のために別々のメソッドを書くのは冗長に感じるかもしれません。
このような場合、モダンなテストフレームワークであるpytest
のparametrize
機能が非常に強力です。これにより、一つのテストメソッドのロジックを、複数の異なる入力データで実行できます。
さらに良い例:parametrize
で関連ケースを整理
import pytest
from validators import is_valid_username
@pytest.mark.parametrize(
"valid_username", ["abcd", "a" * 12, "a" * 20]
)
def test_is_valid_username_valid_cases(valid_username):
"""正常系のユーザー名でTrueを返すことを確認する。"""
assert is_valid_username(valid_username)
@pytest.mark.parametrize(
"invalid_username", ["abc", "a" * 21, ""]
)
def test_is_valid_username_invalid_cases(invalid_username):
"""異常系のユーザー名でFalseを返すことを確認する。"""
assert not is_valid_username(invalid_username)
このアプローチは、両方の解決策の「良いとこ取り」です。
- 関心事の分離:
valid_cases
とinvalid_cases
という、論理的なまとまりでテストが分離されています。 - コードのDRY原則: 似たようなテストケースのための冗長なコードがなくなりました。
- 明確な失敗レポート:
pytest
は、どのパラメータ(例:"a" * 21"
)でテストが失敗したかを正確に報告してくれるため、診断能力も非常に高いです。
まとめ
診断能力の高い、価値あるユニットテストを書くための指針はシンプルです。
- 1つのテストメソッドでは、1つの論理的な振る舞いや仕様のみを検証する。
- テストメソッドの名前は、「何をテストしているか」が明確にわかるように命名する。
- 同じ振る舞いに対する複数のテストケースは、
pytest.mark.parametrize
を使って整理する。
この原則に従うことで、テストは単なる「動くかどうかの確認」から、アプリケーションの仕様を物語る生きたドキュメントへと昇華し、未来の開発における強力な味方となります。