初心者データサイエンティストの備忘録

調べたことは全部ここに書いて自分の辞書を作る

【Python】pytestの基本

pytestについて勉強したのでまとめました。

pytestとは

pytestとは、単体テストを支援するPythonのモジュールの一つです。基本的には、

def test_hogehoge():
    assert テスト内容

の形でテストケースを書いてテストを行います。 私の場合、pytestとVisual Studio Codeと組み合わせて単体テストを行っています。

テストの実装

pytestを用いて単体テストを行う際によく使うディレクトリ構造があります。それが図1です。

図1:基本のディレクトリ構造

頭にtestとついたファイルを、pytestは単体テスト用のファイルと認識します。

実装

問題設定

フィボナッチ数列を計算するスクリプトを作って、それをテストしてみようと思います。 ちなみに、フィボナッチ数列の定義は、


F_0 = 1 \\
F_1 = 1 \\
F_n = F_{n-1}+F_{n-2}

となっている数列です。 今回は、メモイズ機能を使って高速化したフィボナッチ数列を生成するメソッドを作成し、

  • メモイズ機能を使ったフィボナッチ数列が適切な数値を返すこと
  • メモイズ機能を使わないメソッドより短い時間で計算できること
    の2点をテストしようと思います。 ディレクトリの構造は図2です。

図2:ディレクトリ構造

fibonacci.py

フィボナッチ数列を計算するスクリプトは下記です。

def fibonacci(n):
    '''
    フィボナッチ数列のシンプルな実装
    args
        n:整数
    
    returns
        n番目のフィボナッチ数列の値
    '''
    
    if n <= 1:
        return 1
    
    return fibonacci(n-1)+fibonacci(n-2)

def fibonacci_with_memo(n : int, fibonacci_num_dict={}) -> int:
    '''
    メモイズ機能を使ったフィボナッチ数列
    args
        n:整数
    
    returns
        n番目のフィボナッチ数列の値
    '''
    
    if n <= 1:
        return 1
    
    # メモの中に計算結果があればそれを使う
    if n in fibonacci_num_dict:
        return fibonacci_num_dict[n]
    
    # メモの中に計算結果がなければ新たに計算しなければならない
    fibonacci_num_dict[n] = fibonacci_with_memo(n-1)+fibonacci_with_memo(n-2)
    return fibonacci_num_dict[n]

なお、メモイズ機能については過去記事をごらんください。 aisinkakura-datascientist.hatenablog.com

test_fibonacci.py

テストしたい内容をメソッド化してテストを行います。その際、メソッドの頭にtestをつけることを忘れないでください。

from src.fibonacci import fibonacci, fibonacci_with_memo

def test_fibonacci_value():
    assert fibonacci_with_memo(0) == fibonacci(0)
    assert fibonacci_with_memo(1) == fibonacci(1)
    assert fibonacci_with_memo(10) == fibonacci(10)
    
    
# 実行時間に関するテスト
## 実行時間測定用メソッド
import time
def count_time_s(method):
    start_time_s = time.perf_counter()
    
    method
    
    end_time_s = time.perf_counter()
    
    return end_time_s-start_time_s

## 実行時間に関するテスト
def test_compare_exexution_time_fibonacci_methods():
    assert count_time_s(fibonacci_with_memo(40)) < count_time_s(fibonacci(40))

test_fibonacci.pyを見て頂くと、メソッドが3つあることが分かります。このうち、pytestがテストをしてくれるメソッドは、test_fibonacci_value()とtest_compare_exexution_time_fibonacci_methods()の2つです。count_time_s()は、test_compare_exexution_time_fibonacci_methods()に必要なメソッドであって、テスト対象のメソッドではありません。

実行!

下図の手順にしたがってテストを実行します。

図3:pytestの実行手順

pytestのデコレータ

ここでは、ちょっとだけ応用的な使い方を紹介します。

パラメータを変えてテストを実行したい場合

先ほどのスクリプトフィボナッチ数列の値が正しいか確認したいとき、下記の書き方をしました。

def test_fibonacci_value():
    assert fibonacci_with_memo(0) == fibonacci(0)
    assert fibonacci_with_memo(1) == fibonacci(1)
    assert fibonacci_with_memo(10) == fibonacci(10)

この書き方でテストをすると、一つ目のassert文でエラーが発生すると二つ目、三つ目のassert文は実行されません。これは実際にテストをする上では不便です。そこで次のような書き方をすると、一つ目のassert文でエラーが発生しても、二つ目、三つ目のassert文に進んでくれます。

@pytest.mark.parametrize(("value"), [
    (0),
    (1),
    (10)
])


def test_fibonacci_value(value):
    assert fibonacci_with_memo(value) == fibonacci(value)

なお、引数が2個以上ある場合は次のような書き方をします。

@pytest.mark.parametrize(("value1", "value2"), [
    (0, 1),
    (1, 2),
    (10, 11)
])


def test_fibonacci_value(value1, value2):
    assert value1+1 == value2

テスト前後で処理を行いたいとき

テスト前後に何らかの処理を行いたいことがあります。
例えば、

  1. ファイル作成
  2. 1で作成したファイルを用いてテスト
  3. ファイルを削除

のような処理をしたい場合があります。

このような処理を実装する最も素直な方法は、下記だと思います。

def test_open_csv_and_fibonacci_value():
    # ファイル作成
    with open("parameters.csv", "w") as f:
        for num in [1, 2, 3, 4, 5]:
            f.write("{}\n".format(num))
    
    # ファイルを読み込む
    with open("parameters.csv", "r") as csv_file:
        csv_reader = reader(csv_file)
        list_of_rows = list(csv_reader)
        
    # 値が正しいか確認する
    for l in list_of_rows:
        assert fibonacci_with_memo(int(l[0]))  == fibonacci(int(l[0]))
        
    # ファイルを削除する
    os.remove("parameters.csv")

ファイルの作成・削除が必要なテストがこの一つだけだったら、このスクリプトで良いのかもしれません。しかし、現実世界においては、ファイルの作成・削除が複数のテストで共通して必要な場合もあります。その度にファイル作成や削除のスクリプトを書いていたら効率が悪いです。DRY原則にも反します。

そこで出てくるのがfixtureです。先ほどのスクリプトは下記のように書き換えることができます。

# ファイルを作成してテスト実行するメソッド
@pytest.fixture
def make_remove_file():
    # ファイル作成
    with open("parameters.csv", "w") as f:
        for num in [1, 2, 3, 4, 5]:
            f.write("{}\n".format(num))
    
    # ファイルを読み込む
    with open("parameters.csv", "r") as csv_file:
        csv_reader = reader(csv_file)
        list_of_rows = list(csv_reader)
        
    yield list_of_rows # 値を一旦返す
    
    # ファイルを削除する
    os.remove("parameters.csv")


# 値が正しいか確認するメソッド
def test_open_csv_and_fibonacci_value(make_remove_file):
    for l in make_remove_file:
        assert fibonacci_with_memo(int(l[0]))  == fibonacci(int(l[0]))

ポイントは、@pytest.fixtureをつけることでmake_remove_file()をtest_open_csv_and_fibonacci_value()内で変数のように扱えることです。 また、yieldを使うことで図4の順序でスクリプトが実行されます。

図4:yieldを使った場合のスクリプト実行順序

この書き方をすることで、複数のテストで共通な前後の処理を行うことができます。

最後に@pytest.fixtureで定義したメソッドに引数を持たせたいときの対処法を記しておきます。 先ほどのスクリプトでハードコーディングしている"parameters.csv"を引数にとるようなmake_remove_file()メソッドを作成します。

# ファイルを作成してテスト実行するメソッド
@pytest.fixture
def make_remove_file():
    
    def _make_remove_file(csv_name):
        # ファイル作成
        with open(csv_name, "w") as f:
            for num in [1, 2, 3, 4, 5]:
                f.write("{}\n".format(num))
        
        # ファイルを読み込む
        with open(csv_name, "r") as csv_file:
            csv_reader = reader(csv_file)
            list_of_rows = list(csv_reader)
            
        yield list_of_rows # 値を一旦返す
        
        # ファイルを削除する
        os.remove(csv_name)
    
    return _make_remove_file
        
# 値が正しいか確認するメソッド
def test_open_csv_and_fibonacci_value(make_remove_file):
    
    for l in make_remove_file(csv_name="parameters.csv").__next__():
        assert fibonacci_with_memo(int(l[0]))  == fibonacci(int(l[0]))

ここでのポイントは2点あります。
1点目は、make_remove_file()は_make_remove_file()を作成しそれを返すメソッドにすることです。(下記の部分)

def make_remove_file():
    def _make_remove_file(): # このメソッドに引数を渡す
        hogehoge

    return _make_remove_file # 入れ子で定義したメソッドを返す

2点目は、make_remove_fileを呼び出すときに.__next__()を付けることです。_make_remove_fileはyieldで値を返すのですが、その際.__next__()をつける必要があります。

以上の2点に注意することで、fixtureで定義したメソッドに引数を渡すことができます。

まとめ

  • pytestは、単体テストを支援するPythonのモジュールの一つ。
  • test_xxx.py内のtest_yyyメソッドがpytestで実行されるテストの対象となる
  • pytestの応用的使い方
    • @pytest.mark.parametrize:複数パラメータをまとめて設定することができる。また、前半のassert文でエラーとなっても、後半のassert文も実行される。
    • @pytest.fixture:テスト前後で共通の処理をしたい場合に設定する。
    • @pytest.fixtureで定義したメソッドに引数を渡す場合:メソッドを入れ子構造にして、中のメソッドを返すメソッドを作成する。また、yieldを使う場合、値の取り出しに.__next__()が必要。