Pytestを用いたAPIテストを初めて実装した時の話

## はじめに
業務で自身が作成したAPIらへの単体テストを実施する際に、pytestを初めて利用しテストコードを作成したので、技術共有と自身の備忘録もかねて書いてみようと思います。

## pytestってなに
pytestはPython言語を用いてテストコードを作成できるオープンソースのフレームワークの１つです。pytestを用いてテストコードを書いていきます。
[pytest公式サイト](https://docs.pytest.org/en/latest/index.html)

素直な使ってみた感想ですが、0からの利用でもわかりやすかったです。今回はAPIを呼び出すテストを実施しましたが、pythonで書かれたコード自体へのテストコードの実装も同じように実装可能です。

## テスト実施の構成図
今回はAWS環境で作成したAPIGatewayを通した単体テスト実施となります。APIGatewayにつながるLamdba関数内から他のAWSサービス、または外部サービスへつながっています。
<img width="80%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1827273/0384f79c-b757-628e-19d6-bf5bb90d265f.png">

テストコードではAPIGatewayをHTTPライブラリの１つである`requests`で呼び出し、その結果が想定結果と正しいかどうかを検証します。

## 事前準備
まずは自身の環境にpipでpytestをインストールします。
```
pip install pytest
```
次にテストファイルを作成します。
ファイル名は「test_\*.py」または「\*_test.py」形式で命名します。
メソッド名は「test」から始まる形式で命名します。
どちらもテスト実行時に自動で命名規則のファイルを検知し、実行してくれます。

## 基本の実装
実際にテストコードを記載していきます。
```test_01.py
import pytest
import requests

# テスト対象のAPI Gatewayエンドポイント
endpoint = "https://~~~"

# HTTPリクエストヘッダー
headers = {
    "Content-Type": "application/json",
    "Accept": "application/json"
}

def test01():
    # 送信するJSONデータ
    data = {
        id = "12345"
    }

    # API GatewayエンドポイントにPOSTリクエストを送信
    response = requests.post(
        endpoint, headers=headers, data=json.dumps(data))

    # ステータスコードの検証
    assert response.status_code == 200
```
関数test01では、`requests`を用いてPOSTリクエストを送信、その結果を受け取りレスポンス内のステータスコードが200正常であるかを確認しています。
ここでいう`assert`が検証部分。ここで想定結果と一致するかを確認しています。ステータスコード以外でもheaders、body内も検証可能です。

***

作成したテストコードをターミナル実行します。実行コマンドは複数方法あります。

1.カレントディレクトリ実行方法（カレントディレクトリ配下にあるテストフォルダ、テストファイル、テストコードを実行する）
```
pytest
```
2.フォルダ指定方法（指定したフォルダ名の配下にあるテストファイル、テストコードを実行する）
```
pytest tests
```
3.ファイル指定方法（指定したファイル名の配下にあるテストコードを実行する）
```
pytest tests/test_01.py
```
4.メソッド指定方法（指定したメソッドのテストコードを実行する）
```
pytest tests/test_01.py::test01
```

***
実際の実行結果が以下のようになります。今回は１ファイルしか存在しないので、どのコマンドでも同じ結果になります。
```
============================================================= test session starts =============================================================
platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0
rootdir: /Users/user/WorkSpace/test
plugins: rerunfailures-13.0
collected 1 item                                                                                                                              

tests/test_01.py .   　　　　　　　　　　　　　　　　　　　　　　[100%]

============================================================= 1 passed in 11.20s ==============================================================
```
出力結果としては、ファイル名の後に「.」(ピリオド)が表示されています。これは実行テストが1つ成功した、という意味になります。もし失敗すると「F」（FailedのF）が表示されます。

***

次に`test_01.py`に失敗するテストコード`def test02()`を追加して、再度テストを実行してみます。

```
$ pytest -x tests
============================================================= test session starts =============================================================
platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0
rootdir: /Users/user/WorkSpace/test
plugins: rerunfailures-13.0
collected 2 items                                                                                                                             

tests/test_01.py .F 　   　　　　　　　　　　　　[100%]

================================================================== FAILURES ===================================================================
___________________________________________________________________ test02 ____________________________________________________________________

    def test02(): 
        # 送信するJSONデータ
        data = {
        }
    
        # API GatewayエンドポイントにPOSTリクエストを送信
        response = requests.post(
            endpoint, headers=headers, data=json.dumps(data))
    
        # ステータスコードの検証
>       assert response.status_code == 200
E       assert 500 == 200
E        +  where 500 = <Response [500]>.status_code

tests/test_01.py:97: AssertionError
=========================================================== short test summary info ===========================================================
FAILED tests/test_01.py::test02 - assert 500 == 200
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
======================================================== 1 failed, 1 passed in 12.19s =========================================================
```
出力結果としてはファイル名の後に「.」(ピリオド)と「F」が並んで出力されています。つまり1つ目(test01())は成功、2つ目(test02())は失敗、と言うことです。
また結果出力後にどこで失敗したのかの詳細が`AssertionError`として出力されています。ここではステータスコード200想定が500で返ってきたため、一致せずテスト失敗となった模様です。

## テスト前処理・後処理の実装
テストコード実行前にテストデータを準備したり、環境変数を変更する処理を追加、またはテストコード実行後に追加されたデータを削除したり、環境変数を元に戻したりなどテスト実行に伴う処理が必要な場合があります。
pytestでは`fixture`(フィクスチャ)を使います。前処理後処理を記載した関数前に`@pytest.fixture`と宣言すれば良いのです。フィクスチャの中には`yield`というものがあります。これはテストコードが実行される部分となり、関数内は、前処理→`yield`→後処理の順で記載します。

もし、フィクスチャ内でエラーになった場合の出力結果は「E」（ErrorのE）が表示されます。テスト実行エラーの場合は「F」になるので区別判断ができます。

関数01へテスト前処理・後処理を追加した一例です。テストデータの挿入や削除の関数は別途定義しており、呼び出しているだけになります。
```test_01.py
@pytest.fixture
def setup_test01():
    # テストデータの挿入
    put_test_data(info_table, test_data)

    # 完了まで待機
    time.sleep(5)

    yield

    # テストデータの削除
    delete_test_data(info_table, test_data)

    # 完了まで待機
    time.sleep(5)

def test01(setup_test01):
    ~~~テストコード~~~
```

## リトライ実装
テスト実行にあたり、連続的なAPIリクエストを行うため、Lambda更新処理がうまくいかずに時々テストがエラーになってしまいます。これらは意図した結果ではないため、一度エラーになってもリトライ実施するようにします。

今回は`pytest-rerunfailures`を利用します。これはpytestのプラグインの1つで、いろいろな条件で不安定なテストに対し再実行をしてくれます。
[PyPIサイト](https://pypi.org/project/pytest-rerunfailures/)

まずは自身の環境にpipでpytest-rerunfailuresをインストールします。
```
pip install pytest-rerunfailures
```
そして実行コマンドにリトライ情報を追加し、ターミナル実行します。リトライ方法をさまざまに指定することが可能です。

1.失敗した全てのテストを再実行したい場合（リトライ回数3回）
```
pytest --reruns 3
```
2.失敗した場合の再実行までの時間を開ける場合（5秒後リトライ）
```
pytest --reruns 3 --reruns-delay 5
```

実際の出力結果が以下のようになります。
```
$ pytest --reruns 3                 
============================================================= test session starts ==============================================================
platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0
rootdir: /Users/user/WorkSpace/test
plugins: rerunfailures-13.0
collected 2 items                                                                                                                              

tests/test_01.py .R  　　　　　　　　　　　　　　　　　　　　　　　　　　 [100%]R [100%]R [100%]F [100%]

~~エラー詳細省略~~

==================================================== 1 failed, 1 passed, 3 rerun in 13.23s =====================================================

```
出力結果としてはファイル名の後に「.」(ピリオド)と「R」が並んで出力されています。今回はリトライ回数を3回に設定したので、「R」が2回続き、3回目のリトライ結果で「F」となっています。


## 便利なpytestオプション機能

例に挙げているテストコードは1、2件と少ないですが、実際は1ファイルに数十件のテストコードを書くこととなります。そんな時におすすめオプションがこちら。
```
pytest　-v
```
`-v`をコマンドにつけると、より詳細にテスト結果が確認できます。（関数ごとの結果が表示）

```
pytest　-x
```
`-x`をコマンドにつけると、テストが失敗した時点で実行を終了します。

## まとめ
今回は単体テストコードをpytestで作成してみたお話を書いてみました。一度テストコードを作成しておけば、テスト実行は一瞬で完了しますし、以降の作業などで修正、改修が入った際にコマンド1つでリグレ確認ができるなど、便利なことが多いです。自身が作成してみて思うのは、テストコードの可読性の向上や、観点や試験内容の整備はきちんとしておかないと、すぐにテストは実行できますが意味のないテスト実行になってしまうので、”管理”の意識は大事だなと感じました。引き続きいろいろ試していければと思います。

## おまけ

[前回記事](https://qiita.com/inacyc_k/items/3ae7c567f273329edfc7)にてCI/CD環境を整備しました。
ここでのCodeBuild実行コマンドに今回作成したpytestのコマンドを追加することが可能です。追加を行うと、修正対応が生じた際に、CodeCommitへGitPushするだけで自動でビルド＋単体テスト実行までできると言うことです。便利〜。

```buildspec.yml
version: 0.2
env:
  variables:
    tag_name: codebuild
    version: 1.0.0
phases:
  install:
    runtime-versions:
      python: 3.11
    commands:
      - pip3 install pytest
      - pip install pytest-rerunfailures
# ~~ ECRへの接続やLambda更新処理 ~~
  post_build:
    commands:
      - echo Test...
      - python -m pytest -v tests/ --reruns 3
```
ここでは”tests”フォルダ配下のテストコードをリトライ3回条件で実行するコマンドを追加しています。テスト結果はテストレポート出力先を指定していないので、CodeBuildのビルドログ上での確認のみになります。もしテスト実行でErrorになった場合、CodeBuildでのビルドエラーになります。


参照：[【AWS公式】pytest によるテストレポートのセットアップ](https://docs.aws.amazon.com/ja_jp/codebuild/latest/userguide/test-report-pytest.html)