🐍

Web API テスト環境に Python をフル活用する

に公開

Web API のテストでは Postman などの専用アプリを使う場合も多いでしょう. しかし, Web API のテストは乱暴に言えば cURL で叩くことができれば十分です. そのため, Web API のテストの根幹となる部分は技術スタックが薄くなることが望ましいです. 単体テストとHTTPリクエストさえあれば, Web API のテストは実装できます.

まあ正直言語は何でもよいのですが, テスト実行がしやすいスクリプト言語で, Notebook のようなライブラリが豊富な言語となると Python が候補に挙がるでしょう.

参考リポジトリではサーバーも Python で実装していますが, Web API テストであればサーバー側の言語とテストコードで使う言語を合わせる必要はありません. 言語を統一したほうが効率的という考え方もありますが, 作りたいものの特性に合わせて言語を選ぶのが健全だと思います.

環境

クライアントコードの生成に OpenAPI Generator を利用しています.

  • OpenAPI Generator 7.12.0

参考リポジトリは以下.

https://github.com/toms74209200/todo-fastapi

自動テスト

Web API に対してデプロイ前後のリグレッションテストとして, 自動テストを用意します. CI/CD ツールでの実行も考慮するため, なるべく薄い技術スタックで実装します. Python の場合は HTTP クライアントである requests[6] と テストフレームワークである pytest が最低でも必要になります. もちろんそれ以外のライブラリでも問題ありません.

先に述べた通り Web API のテストは端的に言えばHTTPリクエストをするだけです. AAAパターンにそって説明すると以下のようになります.

Arrange

リクエストに必要なデータを準備します. リクエストヘッダやリクエストボディの設定, 必要があればユーザー作成や認証情報を設定します. ユーザー作成などでテスト対象外のAPIを叩く必要があるとき, この段階で直接このAPIを利用します. テスト対象以外のAPIを利用せずに, テスト用のデータを用意する方法もありますが, 私は実際のユースケースを重視して, そのままテスト対象外のAPIも利用しています.

Act

テスト対象のAPIにリクエストを送信します.

Assert

レスポンスの内容を検証します. 例えば, ステータスコードが200であることを確認したり, レスポンスボディに特定の値が含まれていることを確認したりします.

では, 具体的なコードを見ていきましょう. 上のリポジトリでは e2etest/ ディレクトリに実装してあります. 以下の例では, タスクの取得APIに対するテストを実装しています. 事前にユーザー作成・認証・タスク作成が必要なため, それぞれのAPIリクエストも行っています.

e2etest/tests/test_get_tasks.py
def test_get_tasks_normal():
    # Arrange: テストデータの準備
    # ユーザー作成
    email = f"{random_string(10)}@example.com"
    password = "password"

    user_response = UsersApi(api_client).post_users(
        user_credentials={"email": email, "password": password}
    )

    # ユーザー認証
    auth_response = AuthApi(api_client).post_auth(
        user_credentials={"email": email, "password": password}
    )
    token = auth_response.token

    # 取得するタスクの作成
    deadline = datetime.datetime.now() + datetime.timedelta(days=1)
    tasks_api = TasksApi(api_client)
    tasks_api.api_client.configuration.access_token = token
    tasks_api.post_tasks(
        post_tasks_request={
            "name": "task1",
            "description": "description1",
            "deadline": deadline.strftime("%Y-%m-%dT%H:%M:%SZ"),
            "completed": False,
        },
    )

    # Act: テスト対象APIへのリクエスト
    get_response = requests.get(
        f"http://{DOMAIN}/tasks?userId={user_response.id}",
        headers={
            "Authorization": f"Bearer {token}",
        },
    )

    # Assert: レスポンスの検証
    assert get_response.status_code == 200
    assert isinstance(get_response.json(), list)
    assert len(get_response.json()) == 1
    assert get_response.json()[0]["name"] == "task1"
    assert get_response.json()[0]["description"] == "description1"
    assert get_response.json()[0]["deadline"] == deadline.strftime("%Y-%m-%dT%H:%M:%SZ")
    assert get_response.json()[0]["completed"] == False

この例ではシンプルなCRUDのAPIのテストを行っていますが, 単なる単体テストフレームワークを使っているだけなので検証さえできれば複雑なAPIでもテストすることができます. 当然 REST API でなくてもテストすることができます.

手動テスト

自動テストではCI/CDなどの回帰性を重視していますが, これとは別に開発中にデバッグなどである特定のAPIの動作を個別に確認したい場合があります. このために手動テストとしてリクエスト内容をそのままスクリプトにしておきます. こういった用途にNotebook形式のツールを使うと便利です[7]. Notebook形式のツールとして最も普及しているのが, そう Jupyter Notebook です.

テストと呼んでいますが, アサーションのような検証コードは書いていません. ただリクエストを送信して, レスポンスを表示するだけのコードです. またAPIに関する一連のリクエスト方法を具体的に示すことで, それ自体がサンプルコードとしてドキュメントの役割も果たします.

上のリポジトリでは manualtest/manual_test.ipynb に一つのファイルとしてまとめています. 以下にタスクの取得APIに関する手動テストの例を示します. 自動テストとほとんど変わりませんが, アサーションによる検証がなく, 実行結果を標準出力に表示して目視で確認できるようになっています.

manualtest/manual_test.ipynb
import datetime

import requests

from lib.api_config import DOMAIN
from lib.utils import random_string
from openapi_gen.openapi_client.api.auth_api import AuthApi
from openapi_gen.openapi_client.api.tasks_api import TasksApi
from openapi_gen.openapi_client.api.users_api import UsersApi
from openapi_gen.openapi_client.api_client import ApiClient
from openapi_gen.openapi_client.configuration import Configuration

api_client = ApiClient(Configuration(host=f"http://{DOMAIN}"))

email = f"{random_string(10)}@example.com"
password = "password"

register_response = UsersApi(api_client).post_users(
    user_credentials={"email": email, "password": password}
)
user_id = register_response.id
print("Register result:", register_response)

auth_response = AuthApi(api_client).post_auth(
    user_credentials={"email": email, "password": password}
)
token = auth_response.token
print("Auth result:", auth_response)

deadline = datetime.datetime.now() + datetime.timedelta(days=1)
api_client.set_default_header("Authorization", f"Bearer {token}")
tasks_api = TasksApi(api_client)
task_response = tasks_api.post_tasks(
    post_tasks_request={
        "name": "task1",
        "description": "description1",
        "deadline": deadline.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "completed": False,
    }
)
print("Task creation result:", task_response)

get_response = requests.get(
    f"http://{DOMAIN}/tasks?userId={user_id}",
    headers={
        "Authorization": f"Bearer {token}",
    },
)

print("Task get status:", get_response.status_code)
print("Response:", get_response.json())

さらにシンプルに cURL で書いた場合も示しておきます. こちらのほうがいろいろ見えるので, 実行結果がわかりやすい場合があります.

manualtest/manual_test.ipynb
!EMAIL=`head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 16`; \
USER_ID=`jq -n "{email: \"$EMAIL@example.com\", password: \"password\"}" | curl -s --json @- "http://$DOMAIN/users" | jq -r .id` && \
TOKEN=`jq -n "{email: \"$EMAIL@example.com\", password: \"password\"}" | curl -s --json @- "http://$DOMAIN/auth" | jq -r .token` && \
DATE=`date -u +"%Y-%m-%dT%H:%M:%SZ"`; \
jq -n "{name: \"task1\", description: \"description1\", deadline:\"$DATE\", completed: false}" | \
curl -s -H "Authorization: Bearer $TOKEN" --json @- "http://$DOMAIN/tasks" > /dev/null && \
curl -v -H "Authorization: Bearer $TOKEN" "http://$DOMAIN/tasks?userId=$USER_ID"

こういったコードは OpenAPI によるドキュメント生成でも同様のものが得られます. OpenAPI によるドキュメント生成の場合は個々のAPIだけのリクエストしか生成されないため, ユーザー作成してから認証といった一連のフローを示すにはこのように Jupyter Notebook などで自分でまとめる必要があります. OpenAPI を使ったドキュメント生成がもう少し表現力が高くなるといいですね...

負荷テスト

負荷テストには Locust という負荷テスト用のフレームワークを利用します. 使い方を簡単に説明すると, アノテーションをつけたメソッドを定義すると, 設定に従ってリクエストしてくれます. リクエストの検証はHTTPステータスコードで自動で行われます. もちろんアノテーションを設定することもできます.

loadtest/locustfile.py
class CreateUserAPI(HttpUser):
    """Test POST /users API"""

    wait_time = between(1, 5)

    @task
    def create_user(self):
        self.client.post(
            "/users",
            json={
                "email": f"{random_string(10)}@example.com",
                "password": random_string(10),
            },
        )

ブラウザインターフェースから実行することもできます.

Locust のブラウザインターフェース: 設定画面

Locust のブラウザインターフェース: サマリー画面

Locust のブラウザインターフェース: チャート画面

Your first test — Locust 2.35.0 documentation https://docs.locust.io/en/stable/quickstart.html

負荷テストは参考リポジトリの loadtest/ に実装しています. ここでもタスクの取得APIに関する負荷テストの例を示します. 負荷テストでは並列にリクエスト処理が行われることになります. 下の例ではリクエストユーザーごとにユーザー作成や認証を行っていますが, 実際には事前にデータを用意しておくほうが望ましいでしょう.

loadtest/locustfile.py
class GetTasksAPI(HttpUser):
    """Test GET /tasks API"""

    wait_time = between(1, 5)

    def on_start(self):
        # Create user and get token using OpenAPI client
        email = f"{random_string(10)}@example.com"
        password = random_string(10)
        api_client = ApiClient(Configuration(host=self.host))
        self.user_response = UsersApi(api_client).post_users(
            user_credentials={"email": email, "password": password}
        )
        auth_response = AuthApi(api_client).post_auth(
            user_credentials={"email": email, "password": password}
        )
        self.token = auth_response.token
        self.headers = {"Authorization": f"Bearer {self.token}"}
        api_client.set_default_header("Authorization", f"Bearer {self.token}")
        tasks_api = TasksApi(api_client)
        deadline = datetime.datetime.now() + datetime.timedelta(days=1)
        tasks_api.post_tasks(
            post_tasks_request={
                "name": f"task_{random_string(5)}",
                "description": f"description_{random_string(10)}",
                "deadline": deadline.strftime("%Y-%m-%dT%H:%M:%SZ"),
                "completed": False,
            }
        )

    @task
    def get_tasks(self):
        self.client.get(
            f"/tasks?userId={self.user_response.id}",
            headers=self.headers,
        )

負荷テストは検証のために考慮すべき事柄が多く, 実装も一筋縄ではいかないことがあります. 事前に自動テストや手動テストで検証環境が整っていれば, 負荷テストも導入しやすいでしょう.


ここまで Python を使って Web API の自動テスト、手動テスト、負荷テストの実装例を紹介しました. やっていることが同じなので, 当然コードもほとんど同じ内容になっています. 負荷テストには専用ツールである Locust を使いましたが, それ以外は極力シンプルな構成になっています. そのためツールに依存した書き方がほとんどなく, テストの目的が変わっても同様の書き方ができるようになっています. 今回は Python を使いましたが, 他の言語であってもこのようなシンプルな構成を意識して構築することで, 簡単に扱いやすい Web API テスト環境を構築できるでしょう.

脚注
  1. pytest · PyPI https://pypi.org/project/pytest/ ↩︎

  2. Project Jupyter | Home https://jupyter.org/ ↩︎

  3. jupyterlab · PyPI https://pypi.org/project/jupyterlab/4.3.5/ ↩︎

  4. Locust - A modern load testing framework https://locust.io/ ↩︎

  5. locust · PyPI https://pypi.org/project/locust/2.32.6/ ↩︎

  6. requests · PyPI https://pypi.org/project/requests/ ↩︎

  7. Computational Notebook https://martinfowler.com/bliki/ComputationalNotebook.html ↩︎

Discussion