2022/09/05

テクノロジー

システム開発の成功はこれで決まる?「テスト」を書こう!

この記事の目次

    導入編

    テストは大きく分けて3種類

    1. ユニットテスト ⇐本メモで触れます
    2. 結合テスト
    3. ステージングテスト(非機能要件テスト)

    コードレビューの関心ごと

    1. ロジックに問題がないか
    2. 実行時制約など非機能要件の満足に影響がないか
    3. コードの可読性

    このうち、1.のロジックの正当性は、フローを目で追っていかないといけないので負荷が高いですよね。
    しかも、一度正常に動いていても、少し変えただけでまたロジックの正当性を確認しないといけません。
    また、レビューする側も人間ですから、失敗する可能性もあり多少の不安は残るでしょう。

    つまり、確実かつ効率よくロジックの正当性を確認する必要があります。
    主な目的は、目視で正当性を確認しなければならない個所を減らすことになります。

    そこでユニットテストですよ

    • テストケースを正しく書けば、プログラムロジックの正しさを証明できる!
    • つまり、ユニットテストをPASSすることで自信をもってデプロイできる!

    いやぁ、

    素晴らしきかなユニットテスト!

    テストはいつ書くの?

    • 個人的には実装するに書く方が良いです。
      • 「テスト駆動開発」といい、テストを先に書いて、そのテストが通るようにコードを書く
      • これが、「コードが満たすべき条件」を定義することになる。要件をコードで持っておくような感じ。

    つまり、

    「テストを書く」→「実装する」→「テストを通す」→「レビューする」→「マージする」

    この繰り返しになります。

    テストに落としこみにくいとか、緊急で実装したくてテストを書く余裕がないような場合は、後回しでもよいと思います。でもちゃんと手元での動作確認はしましょう。そして、テストは後でちゃんと書きましょう。

    Pythonでのテスティングフレームワーク

    Pythonに限らずですが、大体のプログラミング言語にはこのユニットテストようのライブラリが開発されています。
    Pythonの場合、次のような二大派閥があります。

    • 一番手軽な unittest
      • デフォルトで入っています。テスト用にテストライブラリをインストールするといった手間が省けます。
      • テストフレームワーク pytest
      • unittestのラッパーみたいな感じで、拡張機能などが豊富にあります。
      • VSCodeのPythonプラグインと連携して、テストが通っているかどうか視覚的に確認できるので便利です。
      • こちらはOSSライブラリとなりますので、pipenv install --dev pytestで開発用にインストールしましょう。
        • 「開発用にインストール」とは、本番稼働にpytestは不要なので手元の作業環境にだけインストールすることです。

    他にも、

    • tox
      • さまざまな環境(pythonバージョン)での動作をテストするための設定を簡単にかける。
      • オープンソースのリポジトリなどで導入されているのをよく見かけます。

    なんてものもあります。

    私がpytest派なので、次からpytestについて書いていこうと思います。

    テストの基本編

    pytestをインストールしよう

    • pytestはほとんどの場合は、本番稼働時には不要なので、pipenvにおいて次のように--devオプションをつけることで、pipenv syncしたときにはインストールされないようにすることができます。
    pipenv install --dev pytest

    pipならこうです。

    pip install pytest

    ソースコード構成

    最もよくとられるソースコード構成は、次のようになっています。
    testsディレクトリをルートに作成する
    ② 本体コードと同じ構成で、test_を接頭辞にもつ.pyファイルを作成する
    ここでは次のような構成であるとして進めていきます。

    <project_root>
    |
    +- src/
    |   `- game.py
    +- tests/
        `- src/
            `- test_game.py

    テストを先に書く

    では、今回は例として、game.pyに、「丁半」のゲームのジャッジを行うメソッドを書くことにしましょう。
    丁半とは、次のようなルールの賭博です。

    1. サイコロ2つを振って出る合計の目が偶数になると思ったら「丁」、奇数になると思ったら「半」に賭ける。
    2. ひっくり返した茶碗の中で、サイコロ2つを振る。
    3. 予想が当たればお金が倍になる。外れればお金は没収される。

    今回は、サイコロ2つを振った合計が偶数か奇数かを判定するメソッドを書きますが、その前にテストを定義します。
    tests/src/test_game.pyに次のように記載します。

    # tests/src/test_game.py
    from src.game import judge_chouhan
    
    def test_judge_chouhan_odd():
        assert judge_chouhan(1, 2) == '半'
    
    def test_judge_chouhan_even():
        assert judge_chouhan(2, 4) == '丁'

    ポイントは、この時点で judge_chouhan()の中身は定義されていないことです。
    つまり、「出目が1と2ならば'半'を返す」、「出目が2と4ならば'丁'を返す」という2つの「テストケース」を定義したことになります。
    なお、このようにテスト対象のモジュールの中身を知ることなく、入出力にだけ着目して行うテストのことを「ブラックボックステスト」と呼びます。

    この状態でテストを実行してみましょう。

    pipenv run pytest -v

    もちろん失敗しますが、「まず失敗するテストを書く」のが重要です。

    ここから、「このテストが通るように」コードを実装していきます。

    # src/test_game.py
    
    def judge_chouhan(n1: int, n2: int) -> str:
        """n1とn2の合計が偶数なら'丁', 奇数なら'半'を返す。
        Args:
            n1, n2 (int): さいころの出目
        Return:
            str : '丁' or '半'
        """
        if (n1 + n2) % 2 == 0:
            return '丁'
        else:
            return '半'

    実装したら、もう一度テストを実行しましょう。

    pipenv run pytest -v

    例外が発生することをテストする

    with pytest.raises()ブロック内で、例外が発生するケースを呼び出すことで、
    例外が発生するかどうかをテストすることができます。

    サイコロの目が1~6以外が指定されたら例外が発生するように変えてみましょう。
    まずはテストケースを追加します。

    # tests/src/test_game.py
    import pytest
    
    def test_judge_chouhan_exception():
        """1~6以外の値が入ったときに例外が発生するかどうかテストする"""
        with pytest.raises(ValueError) as excinfo:
            judge_chohan(7, 1)
        # 例外メッセージをテストする
        assert str(excinfo.value) == "invalid eyes of die: n1 = 7"

    そして本体コードに例外処理を追加しましょう。

    # src/test_game.py
    
    def judge_chouhan(n1: int, n2: int) -> str:
        """n1とn2の合計が偶数なら'丁', 奇数なら'半'を返す。
        Args:
            n1, n2 (int): さいころの出目
        Return:
            str : '丁' or '半'
        Raises:
            ValueError: 不正なサイコロの目が入力された場合
        """
        assert (n1 - 1) * (n1 - 6) <= 0, ValueError("invalid eyes of die: n1 = %d" % n1)
        assert (n2 - 1) * (n2 - 6) <= 0, ValueError("invalid eyes of die: n2 = %d" % n2)
    
        if (n1 + n2) % 2 == 0:
            return '丁'
        else:
            return '半'

    mock編

    関数が実行されたかどうかのテスト

    pytest-mockを使えば、テスト実行時だけ、メソッドやクラスを「実行したフリ」をすることができます。
    たとえば、あるモデルの学習を行うメソッドsome_model.train()が次のようになっているとします。

    # some_model.py
    from sklearn.linear_model import LogisticRegression
    
    def train(params: Dict):
        """paramsをハイパーパラメータとして学習する。"""
        X, y = get_data()
        clf = LogisticRegression(**params)
        clf.fit(X, y)
    
        return clf

    そして、それを使ってハイパーパラメータ調整を行うsome_model.grid_search()が次のようになっているとします。

    # some_model.py
    from sklearn.model_selection import ParameterGrid
    
    def grid_search(param_grid: Dict[str, List]):
        """param_gridから探索空間を生成し、その空間でグリッドサーチを行う。"""
        param_grid = ParameterGrid(param_grid)
        for hparam in param_grid:
            clf = train(hparam)
    
                : (中略)
    
        return best_model

    ここで、これらのメソッドが正常に作動するかどうかテストします。テスト要件は次の通りです。

    • train()は、paramsをディクショナリで受け取って、LogisticRegressionオブジェクトを返す。
    • grid_search()は、グリッドサーチの探索空間(引数のparam_grid)を次のように文字列->リストのディクショナリ型で受け取る。それをもとにグリッドサーチの探索空間(直積)を生成し、そのすべての要素についてtrain()を実行する。もっともよい性能を示したLogisticRegressionオブジェクトを返す。
    # param_grid
    {'regularization': ['l1', 'l2', 'l1_l2'], 'loss': ['rmse', 'mae']}

    この2つのメソッドをテストしようとすると、grid_search()のテストでtrain()を何度も実行することになるので、train()の実行時間が長いとgrid_search()がとても長くなり非効率です。

    grid_search()はtrain()が正常に動作すれば、あとは最高の性能を示したモデルを返すだけなので、

    • train()が正しく動作することを確認する
    • grid_search()が、与えたハイパーパラメータから生成される探索空間のすべての要素に対して実行されることを確認する

    だけでよいはずです。 train()が正しく動作することを確認するのはtrain()のテストで可能なので、grid_search()のテストで関心があるのはtrain()がどのパラメータで実行されたか、だけです。

    そこで、grid_search()のテスト時にtrain()を実行せずに、実行時のパラメータだけを確認したいときに使うのがpytest-mockです。

    テストメソッドは次のようになります。

    # test_some_model.py
    from sklearn.linear_model import LogisticRegression
    
    def test_grid_search(mocker):
        """
        some_model.grid_search()のユニットテスト。
        実際に実行するときは探索空間すべてに対してtrain()を実行するが、
        ユニットテストではtrain()を実行することなく、どのパラメータで実行されたかだけを確認する。
        """
        mocker.patch("some_model.train", return_value=LogisticRegression())
        from some_model import grid_search
    
        param_grid = {"hp_a": [1, 2], "hp_b": [3, 4]}
    
        clf = grid_search(param_grid)

    ポイントはmocker.patchを実行する位置です。
    モックしたいモジュールが直接ないし間接的にimportされる前にmocker.patchをしないとモックしてくれません。
    from some_model import grid_searchを実行すると、同時にsome_model.py内で作成されているtrainもロードされてしまうため、先にimportを実行してしまうとモックが効かなくなります。そのため上記のコード例のようにimportする前にモックする必要があるのです。

    ※本記事は2022年09月時点の情報です。

    著者:マイナビエンジニアブログ編集部