【pytest入門】 pytestをさわってみる

# 概要
本記事はアイレット株式会社[Advent Calendar2023](https://qiita.com/advent-calendar/2023/iret) " 9日目の記事です！

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

# pytestとは
pytestとは、Pythonで書かれたテストフレームワークの一つです。
Pythonのテストフレームワークは、他にも標準フレームワークであるunittestや外部フレームワークのnoseなどもあります。
pytestの特徴としては、以下のような点が挙げられます。
<dl> 
<dt>シンプルなテストの作成</dt> 
<dd>assertステートメントを使用してテストコードを比較的簡単に記述できる。</dd>
<dt>詳細なアサーションエラーメッセージ</dt> 
<dd>失敗したアサーションに対して詳細なエラーメッセージが提供し、デバッグが容易になる。</dd>
<dt>パラメータ化テスト</dt>
<dd>pytest.mark.parametrize デコレータを使用して、同じテストを異なるパラメータで繰り返し実行することができる。</dd>
<dt>フィクスチャ</dt> 
<dd>フィクスチャを使用すると、テストのセットアップとクリーンアップのロジックを再利用できる。</dd>
<dt>豊富なコマンドラインオプション</dt> 
<dd>テストの実行方法を細かく制御するための多数のコマンドラインオプションを提供している。</dd>
<dt>多くのプロジェクトとの互換性</dt> 
<dd>単純なスクリプトから複雑なアプリケーション、ライブラリまで幅広いプロジェクトで使用することができる。</dd>
</dl>

# つかってみる

## 環境
macOS
Python: 3.9.6
pytest: 7.4.0
## ディレクトリ構成
今回は以下のような構成で検証しています。
```
.
├── src
│   └── code.py
└── tests
    ├── __init__.py
    └── test.py
```

## 基本系
```python:code.py
def sum_numbers(a, b):
    return a + b
```
上記のような、足し算の結果を返す関数に対して以下のようなテストコードを作成することで、テストすることができます。
下記のテストコードでは、テスト関数内で上記の関数を呼び出し、関数の戻り値と期待値が一致しているかassertしています。

```python:test.py
def test_sum_numbers():
    result = code.sum_numbers(3, 4)
    assert result == 7
```

ターミナルでテストを実行します。
```
$ pytest tests/test.py
```
以下のように一つの関数に対してテストが通っていることがわかります。
```
=================================================================== test session starts ===================================================================
platform darwin -- Python 3.9.6, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/user/Documents/hoge
collected 1 item                                                                                                                                          

tests/test.py .                                                                                                                                     [100%]

==================================================================== 1 passed in 0.00s ====================================================================
```

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

ファイル操作を行うコードを例に検証します。
```python:code.py
# 一時ファイルにデータを書き込む関数
def write_file(path):
    with open(path, 'w') as f:
        f.write('test data')
```
fixtureを使用し、テスト関数が実行される前処理として、一時ファイルを作成し、後処理としてテスト関数実行後にファイルを削除します。
テスト関数の引数にfixtureの関数名を指定します。
関数に対して、以下のようにデコレータとしてfixtureをセットすることで、テストコードの前処理を定義できます。
yieldの下の行に後処理を記述することで、テスト関数実行後に定義した後処理を実行することができます。
```python:test.py
@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"
```

```
=================================================================== test session starts ===================================================================
platform darwin -- Python 3.9.6, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/user/Documents/hoge
collected 1 item                                                                                                                                          

tests/test.py .                                                                                                                                     [100%]

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

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

以下の２つの数値の積を返す関数に対してテストを行います。
```python:code.py
# テストする関数（例：二つの数値の積を返す）
def multiply(a, b):
    return a * b
```
ここでは、定義された5つのタプルが順番に使用され関数が５回実行されます。
```python:test.py
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
```
```
=================================================================== test session starts ===================================================================
platform darwin -- Python 3.9.6, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/user/Documents/hoge
collected 5 items                                                                                                                                         

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

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

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

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

```python:code.py
# テスト対象の関数（例：外部データソースからデータを取得）
def get_external_data():
    # 実際には外部APIを呼び出すなどの処理
    return "外部APIなどのから取得したデータ"
```
テスト用のモック関数mock_get_external_dataを定義し、テスト中に実際の関数の代わりに使用します。
monkeypatch.setattrを使って、get_external_data関数をmock_get_external_dataに置き換えます。
モックを使用して関数を呼び出し、期待される結果（モックからの戻り値）が得られるかを検証します。
```python:test.py
# テストで使用するモック関数
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"
```
```
=================================================================== test session starts ===================================================================
platform darwin -- Python 3.9.6, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/r-kobayashi/Documents/memo/memo
collected 1 item                                                                                                                                          

tests/test.py .                                                                                                                                     [100%]

==================================================================== 1 passed in 0.01s ====================================================================
```
このように、外部依存関係を持つ関数を簡単にテストでき、外部APIの呼び出しや他の外部操作の影響を受けることなくテストを行うことができます。
# 最後に
今回は自分が業務の中で特に使用した機能についてまとめました。
表面的ではありますが、pytestの基本的な機能を学び直すことができました。
これらの他にも様々なサードパーティプラグインやテストの実行方法を制御するコマンドラインなどがあるそうです。
今後触れる機会があれば、他の機能やより詳細な内容についてまとめてみたいと思います！
読んでいただきありがとうございます！

# 参考文献
* https://docs.pytest.org/en/latest/how-to/assert.html
* https://docs.pytest.org/en/stable/explanation/fixtures.html
* https://docs.pytest.org/en/stable/how-to/parametrize.html
* https://zenn.dev/onigiri_w2/articles/5e6cf4d3ba9ed5
* https://zenn.dev/tk_resilie/articles/python_test_template