抂芁

案件の䞭で単䜓テストを行う際に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 デコレヌタを䜿甚するこずで、同じテスト関数を異なる匕数セットで耇数回実行するこずができたす。関数が異なる入力でどのように動䜜するかをテストする堎合に䟿利な機胜です。

以䞋の぀の数倀の積を返す関数に察しおテストを行いたす。

code.py

# テストする関数䟋二぀の数倀の積を返す
def multiply(a, b):
    return a * b

ここでは、定矩された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の基本的な機胜を孊び盎すこずができたした。
これらの他にも様々なサヌドパヌティプラグむンやテストの実行方法を制埡するコマンドラむンなどがあるそうです。
読んでいただきありがずうございたす

参考文献