概要

案件の中で単体テストを行う際にpytestを使用する機会があり、その際に学んだことや使用した機能についてまとめたいと思いこのテーマで作成しました。
pytestとは何か、どのような機能があるか知るきっかけにしていただけたら嬉しいです。

pytestとは

pytestとは、Pythonで書かれたテストフレームワークの一つです。
Pythonのテストフレームワークは、他にも標準フレームワークであるunittestや外部フレームワークのnoseなどもあります。
pytestの特徴としては、以下のような点が挙げられます。

シンプルなテストの作成
assertステートメントを使用してテストコードを比較的簡単に記述できる。
詳細なアサーションエラーメッセージ
失敗したアサーションに対して詳細なエラーメッセージを提供し、デバッグが容易になる。
パラメータ化テスト
pytest.mark.parametrize デコレータを使用して、同じテストを異なるパラメータで繰り返し実行することができる。
フィクスチャ
フィクスチャを使用すると、テストのセットアップとクリーンアップのロジックを再利用できる。
豊富なコマンドラインオプション
テストの実行方法を細かく制御するための多数のコマンドラインオプションを提供している。
多くのプロジェクトとの互換性
単純なスクリプトから複雑なアプリケーション、ライブラリまで幅広いプロジェクトで使用することができる。

つかってみる

環境

macOS
Python: 3.11.0
pytest: 8.3.3

ディレクトリ構成

今回は以下のような構成で検証しています。

.
├── src
│   └── code.py
└── tests
├── __init__.py
└── test.py

基本系

code.py

def sum_numbers(a, b):
    return a + b

上記のような、足し算の結果を返す関数に対して以下のようなテストコードを作成することで、テストすることができます。
下記のテストコードでは、テスト関数内で上記の関数を呼び出し、関数の戻り値と期待値が一致しているかassertしています。

test.py

from src import code

def test_sum_numbers():
    result = code.sum_numbers(3, 4)
    assert result == 7

ターミナルでテストを実行します。

$ pytest tests/test.py

以下のように一つの関数に対してテストが通っていることがわかります。

pytest tests/test.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.11.0, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/pytest
collected 1 item                                                                                                                                      

tests/test.py .                                                                                                                                 [100%]

================================================================== 1 passed in 0.01s ==================================================================

fixture

fixtureを使用することで、テストのための前準備(セットアップ)や後処理(クリーンアップ)をテスト関数から分離して行うことができます。
また、それらの処理を複数のテスト間で共有したり、自動的に使用するように設定することができます。
特に、DB接続やファイルシステムの使用など事前作業や後処理が必要な場合に役立ちます。

ファイル操作を行うコードを例に検証します。

code.py

# 一時ファイルにデータを書き込む関数
def write_file(path):
    with open(path, 'w') as f:
        f.write('test data')

fixtureを使用し、テスト関数が実行される前処理として、一時ファイルを作成し、後処理としてテスト関数実行後にファイルを削除します。
テスト関数の引数にfixtureの関数名を指定します。
関数に対して、以下のようにデコレータとしてfixtureをセットすることで、テストコードの前処理を定義できます。
yieldの下の行に後処理を記述することで、テスト関数実行後に定義した後処理を実行することができます。

test.py

import tempfile

import pytest
from src import code


@pytest.fixture
def temp_file():
    fd, path = tempfile.mkstemp()
    os.close(fd)  # ファイルディスクリプタを閉じる
    yield path  # テストにファイルパスを提供
    os.remove(path)  # テスト後にファイルを削除


# フィクスチャを使用するテスト関数
def test_write_file(temp_file):
    code.write_file(temp_file)  # ファイルにデータを書き込む

    # ファイルの内容を確認
    with open(temp_file, 'r') as f:
        assert f.read() == "test data"
pytest tests/test.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.11.0, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/pytest
collected 1 item                                                                                                                                      

tests/test.py .                                                                                                                                 [100%]

================================================================== 1 passed in 0.01s ==================================================================

mark.paramiterize

pytest の @pytest.mark.parametrize デコレータを使用することで、同じテスト関数を異なる引数セットで複数回実行することができます。関数が異なる入力でどのように動作するかをテストする場合に便利な機能です。

以下の2つの数値の積を返す関数に対してテストを行います。

code.py

# テストする関数(例:二つの数値の積を返す)
def multiply(a, b):
    return a * b

ここでは、定義された5つのタプルが順番に使用され関数が5回実行されます。

test.py

import pytest
from src import code

data = [
    (2, 3, 6),  # 2 * 3 = 6
    (5, 5, 25),  # 5 * 5 = 25
    (0, 10, 0),  # 0 * 10 = 0
    (-1, -1, 1),  # -1 * -1 = 1
    (-1, 2, -2)  # -1 * 2 = -2
]


# テストケースをパラメータ化
@pytest.mark.parametrize("a, b, expected", data)
def test_multiply(a, b, expected):
    assert code.multiply(a, b) == expected
pytest tests/test.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.11.0, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/pytest
collected 5 items                                                                                                                                     

tests/test.py .....                                                                                                                             [100%]

================================================================== 5 passed in 0.01s ==================================================================

monkeypatch

pytestでmonkeypatchフィクスチャを使用すると、テスト中に関数、環境変数、属性などを動的に変更することができます。これは、テストのためにコードの挙動を一時的に変更したい場合に使えます。例えば、外部のAPIを呼び出す関数をモックすることなどが可能です。

外部サービスからデータを取得する関数get_external_dataを作成し、この関数の戻り値をテスト中にモックします。

code.py

# テスト対象の関数(例:外部データソースからデータを取得)
def get_external_data():
    # 実際には外部APIを呼び出すなどの処理
    return "外部APIなどのから取得したデータ"

テスト用のモック関数mock_get_external_dataを定義し、テスト中に実際の関数の代わりに使用します。
monkeypatch.setattrを使って、get_external_data関数をmock_get_external_dataに置き換えます。
モックを使用して関数を呼び出し、期待される結果(モックからの戻り値)が得られるかを検証します。

test.py

import pytest
from src import code


# テストで使用するモック関数
def mock_get_external_data():
    return "mocked_data"


# テスト関数
def test_external_data(monkeypatch):
    # get_external_data関数をモック関数で置き換え
    monkeypatch.setattr("src.code.get_external_data", mock_get_external_data)

    # モックを使用してテストを実行
    result = code.get_external_data()
    assert result == "mocked_data"```
pytest tests/test.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.11.0, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/pytest
collected 1 item                                                                                                                                      

tests/test.py .                                                                                                                                 [100%]

================================================================== 1 passed in 0.01s ==================================================================

moto

motoはAWSサービスを使用したコードの単体テストをモックしてくれるライブラリです。
今回はS3を例として行いますが、sqs, sesなど幅広いサービスに対応しているようです。
S3にファイルをアップロードするコードを作成し、モックしたS3環境で単体テストを行うことができるか検証します。

code.py

import boto3

def upload_to_s3(local_file_path, bucket_name, s3_file_name):
    s3 = boto3.client('s3')
    s3.upload_file(local_file_path, bucket_name, s3_file_name)

S3のモック環境を作成し、そこにファイルをアップロードし、正しくファイルがアップロードされているか確認します。
上記で記述したfixtureの機能を使用し、テストコードを呼び出す前後にファイル作成処理と削除処理を行うようにします。

test.py

import boto3
import pytest
from moto import mock_aws
from src.code import upload_to_s3
import os


@pytest.fixture
def s3_setup():
    # `moto` を使った S3 モックの作成
    with mock_aws():
        # S3のモック環境をセットアップ
        s3 = boto3.client('s3', region_name='us-east-1')
        bucket_name = "test-bucket"
        s3.create_bucket(Bucket=bucket_name)

        # 一時ファイルを作成
        test_file_path = "test_file.txt"
        with open(test_file_path, "w") as f:
            f.write("This is a test file.")

        yield {
            'bucket_name': bucket_name,
            'test_file_path': test_file_path
        }

        # テスト終了後にファイルを削除
        if os.path.exists(test_file_path):
            os.remove(test_file_path)


def test_upload_to_s3(s3_setup):
    # モックされたS3バケットにファイルをアップロード
    upload_to_s3(s3_setup['test_file_path'], s3_setup['bucket_name'], 'uploaded_test_file.txt')

    # S3クライアントを作成してアップロードが成功したか確認
    s3 = boto3.client('s3')
    # head_objectでアップロードされたファイルの存在を確認
    response = s3.head_object(Bucket=s3_setup['bucket_name'], Key='uploaded_test_file.txt')

    # head_objectが例外を投げなければファイルが存在することになる
    assert response is not None
pytest tests/test.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.11.0, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/pytest
collected 1 item                                                                                                                                      

tests/test.py .                                                                                                                                 [100%]

================================================================== 1 passed in 0.39s ==================================================================

最後に

今回は自分が業務の中で特に使用した機能についてまとめました。
表面的ではありますが、pytestの基本的な機能を学び直すことができました。
これらの他にも様々なサードパーティプラグインやテストの実行方法を制御するコマンドラインなどがあるそうです。
読んでいただきありがとうございます!

参考文献