【Python】ユニットテストの作法:1テスト1アサーションの原則とpytestでの実践

ユニットテストの目的は、コードが正しく動作することを確認するだけではありません。テストが失敗したときに、**「何が、どのように、なぜ間違っているのか」**を迅速かつ明確に開発者に伝えることも、同様に重要な役割です。

この「診断能力」の高いテストを書くための指針として、**「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つの問題があります。

  1. 曖昧な失敗レポート: もしこのテストが失敗した場合、テストランナーは単にtest_is_valid_usernameが失敗したことしか教えてくれません。「短すぎた」のか「長すぎた」のか、あるいは「正常なはずのものが弾かれた」のか、失敗の具体的な原因を特定するために、コードの内部まで見に行く必要があります。
  2. エラーのマスキング: テストは、アサーションが一つでも失敗した時点で実行を停止します。例えば、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パターン)のために別々のメソッドを書くのは冗長に感じるかもしれません。

このような場合、モダンなテストフレームワークであるpytestparametrize機能が非常に強力です。これにより、一つのテストメソッドのロジックを、複数の異なる入力データで実行できます。

さらに良い例: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_casesinvalid_casesという、論理的なまとまりでテストが分離されています。
  • コードのDRY原則: 似たようなテストケースのための冗長なコードがなくなりました。
  • 明確な失敗レポート: pytestは、どのパラメータ(例:"a" * 21")でテストが失敗したかを正確に報告してくれるため、診断能力も非常に高いです。

まとめ

診断能力の高い、価値あるユニットテストを書くための指針はシンプルです。

  • 1つのテストメソッドでは、1つの論理的な振る舞いや仕様のみを検証する。
  • テストメソッドの名前は、「何をテストしているか」が明確にわかるように命名する。
  • 同じ振る舞いに対する複数のテストケースは、pytest.mark.parametrizeを使って整理する。

この原則に従うことで、テストは単なる「動くかどうかの確認」から、アプリケーションの仕様を物語る生きたドキュメントへと昇華し、未来の開発における強力な味方となります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次