🤔

なぜややこしいUTを実装しなければならないのか

2024/11/05に公開

こんにちは!プロ雀士でプログラマーのおおのです!
気づけば前回の投稿からはや1年、、、
あれからフロントエンドは少し離れまして、今はPythonの開発をメインでやっております!
Pythonは長らく使っているけれど、ユニットテスト(以下UT)を実装し始めたのはつい最近。そこで今回は、pytestにおけるUTの重要性について書いていこうと思います!

pytestの書き方

まず何より取っ掛かりづらかった点として、Pythonで書いている内容とは違った記述方法が結構あることでした。mockの定義、conftestでのfixtureの使い方、return_value,side_effectのオプションを使用した返り値のテストなど、それまで処理を通すことを目的として実装を進めていた僕としてはかなり新鮮なものでした。

少し調べてみるとunittestやdoctestなどのフレームワークがあるようですが、pytestには以下のメリットがあるようです!

・書き方がシンプル
・デバッグのしやすさ

確かに慣れれば書き方はシンプルで、ある程度GPTに雛形を作ってもらってから実際のロジックに沿って修正していくことで、1から書くよりも実装コストが大幅に削減できます!
デバッグに関しても、プラグインを導入することで通常のPythonと同様に行うことができるので、UT自体の修正に手間がかからないこともわかりました。

return_value:

メソッドや関数が呼ばれたときに常に同じ値を返すように指定するオプション。シンプルな戻り値を必要とするテストケースで使用する。

side_effect:

特定の例外を発生させたり、複数の異なる戻り値を順番に返すオプション。オブジェクトが呼び出されたときの挙動をカスタマイズするために使用する。

mockとは

今回はその中で最も取っ掛かりづらかったmockについて書こうと思います。
mockには「模倣する」という意味があり、テストでは「実際の環境を模倣したデータや振る舞いを提供する」というニュアンスがあるそうです。
我々のプロジェクトでは以下のような書き方で、メソッドやデータをmockして使用していました。

メソッドをmockする例

def test_get_user_data_error(mocker):
    # エラーメッセージをMockする
    mocker.patch.object(UserService, "get_user_data", side_effect=Exception("API error"))

    service = UserService()

    with pytest.raises(Exception) as excinfo:
        service.get_user_data(1)

    assert str(excinfo.value) == "API error"

データをmockする例

    # Mockデータの作成
    mock_data = pd.DataFrame({
        "id": [1, 2, 3],
        "status": ["active", "inactive", "active"]
    })

    # 期待されるデータ
    expected_data = pd.DataFrame({
        "id": [1, 3],
        "status": ["active", "active"]
    }).reset_index(drop=True)

    # テスト対象の処理: "status"が"active"の行のみ抽出
    result_data = mock_data[mock_data["status"] == "active"].reset_index(drop=True)

    # DataFrameの内容を比較
    pd.testing.assert_frame_equal(result_data, expected_data)

conftestでfixtureを使用してDataFrameのmockデータを利用する例

# conftest.py
@pytest.fixture
def mock_dataframe():
    # CSV形式のMockデータ
    csv_data = StringIO("""
    id,status
    1,active
    2,inactive
    3,active
    """)
    # CSVをDataFrameに変換
    df = pd.read_csv(csv_data)
    return df

# test_example.py
def test_filter_active_rows(mock_dataframe):
    """
    | 正常系テスト
    | テスト対象: DataFrameのフィルタリング
    | テスト内容: DataFrame内の"status"が"active"の行のみが抽出されるか
    | 期待結果: "status"が"active"の行が含まれ、他の行は除外される
    """
    # 期待されるデータ
    expected_data = pd.DataFrame({
        "id": [1, 3],
        "status": ["active", "active"]
    }).reset_index(drop=True)

    # テスト対象の処理: "status"が"active"の行のみ抽出
    result_data = mock_dataframe[mock_dataframe["status"] == "active"].reset_index(drop=True)

    # DataFrameの内容を比較
    pd.testing.assert_frame_equal(result_data, expected_data)

正直なところ言うと、「これって意味あるのか、、、?」と思ったのが最初でした。
使用するデータのみならず、途中のメソッドまでmock化するのか。実装したのは自分なのに、UTが通ったからといって処理が通っている実感がなかったからです。初めは書いてあることがわからず、何なら処理が通っているのを確認しているのにUTだけが通らないということも多くありました(笑)

まあそれもそのはずなんです。UTは実際の処理が通っているかどうかを確認するのが主目的では無いからです。
では何が目的なのでしょうか。それは次のセクションでお話します。

UTを実装する目的

UTを実装する目的は様々ですが、大きな点を挙げると以下の4つかと思います!

1.Exceptionが正しく機能しているのか

システムによっては、エラーが発覚してから1,2時間で解決して再実行しなければならないこともあるかと思います。そんな中でエラーの早期発見というのは大事になってきますよね。
UTを実装して正しい場所で正しくExceptionしていることが確認できていれば、いざエラーログを見た時にそのメソッドにだけ注力すれば良いのです。

前のプロジェクトではUTを実装していなかったので、予想もしない箇所でExceptionが発生していて、エラー箇所の発見に時間がかかったこともありました。

2.データの型の共通認識を合わせる

チーム開発では、「このデータはどういった型でやり取りしてるのか」といういちいち確認しなければならないシーンは少なくないかと思います。UTを実装していれば、assertで合わせているデータの型が実際のデータの型なので、ログ出力や設計書などで確認する手間が省けます!

3.途中で関数を変更したときの修正のしやすさ

仕様の変更や後続処理との兼ね合いで、「元々実装していた関数を修正する」なんてことはよくあるかと思います。特に外部APIを利用している時などは、実際にAPIを通さなくともロジック部分を確認することができるので、デバッグがしやすく修正コストが下がります。

※ただし、変更した関数が間違っていてUTが通っていないのか、関数の変更によりUTも変更が必要なのかというのはその都度チェックしなければいけないです。

4.CI/CDで処理をチェックしてくれる

関数を変更したときの修正のしやすさに関連することでもあるのですが、「UTが通っていなければデプロイを通さない」というCI/CDフローを組むことができます。
これはデータの型エラーであったり、修正が修正を生んでしまうリスクを極端に下げていますよね。

プロンプト

せっかくなので雛形を作ってもらっているプロンプトも紹介しておきます!

プロンプトの例

あなたはpytestのプロフェッショナルです。次の条件でユニットテストのテストケースとconftest.pyを生成してください。

  • 使用ライブラリ: pytest、pytest-mockのみ(unittestや関連モジュールは使用不可)
  • conftest.py命令:
    • 共通のセットアップやティアダウン処理を含め、pytestとpytest-mockのみを使用すること。
  • テスト命令:
    • 全てのExceptionを通過し、個別にテストすること
    • カバレッジ100%で正常系と異常系を含むこと
    • mocker.patch.objectでパスを指定すること
    • 各関数の全てのパスを網羅すること
    • コメント形式は以下を使用すること
"""
| {正常系/異常系}テスト
| テスト対象クラス: {クラス名}
| テスト対象メソッド: {メソッド名}
| テスト内容: {テスト内容}
| 期待結果: {期待結果}
"""

まとめ

初めは実装も保守も合わせて2倍くらいのコストがかかっているイメージだったのですが、慣れてしまえばすんなり書けるし、長期的に見るとUTがもたらすメリットは実装コストを超えると実感しました!

ただし、上記で説明したメリットに関しては全てUTが綺麗に整備されている前提での話になるかと思います!逆にいうとメリットをちゃんと意識していればチームでUTの整備にも力を注げますね。

以上でした!これからUTを実装しようか迷っている方の手がかりになれば幸いです!!

O-KUN Tech Blog

Discussion