behaveを使ってAzure Functions(Python版)でBDD(振る舞い駆動開発)を始める
この記事はbehaveを使ってAzure Functions(Python版)でBDD(振る舞い駆動開発)を始めるためのメモです。
Azure FunctionsはちょっとしたPythonAPIを動かすのにちょうど良いFaaS(Function as a Service)です。
今回はインテグレーションテスト環境をbehaveを使って整えます。
behaveはBDD(振る舞い駆動開発)を行う上で有効なライブラリです。
特徴としてはGherkinフォーマットで振る舞いを記述し、pythonで実行するコードを記述するため、仕様と実装を分けることができます。
これにより役割分担ができるだけでなく、仕様の可読性も格段に上がります。
ここでは、過去に作成したAzure Functionsに対してbehaveを使ってインテグレーションテストを書き進めていきます。
過去に作成したAzure Functionsについては下記の通り。
- 基本編
- Azure Functions編
今回はAzure Functions編のため、作業ディレクトリは下記になります。
~/devel/sandbox_azure_functions
1. hatchにインテグレーションテストの設定を追加し、実行する
1.1. pyproject.tomlにbehave用の設定を記入し、環境を整える
pyproject.toml
ファイルの末尾に、behave用の設定を追記します。
〜〜〜〜〜(前略)〜〜〜〜〜
".venv",
]
+ [tool.hatch.envs.integration]
+ detached = true
+ dependencies = [
+ "behave",
+ "requests",
+ ]
+ [tool.hatch.envs.integration.scripts]
+ all = [
+ "behave",
+ ]
+ [tool.behave]
+ lang = "ja"
+
次にbehave用のフォルダを作成します。
mkdir -p features/steps/
1.2. アプリの振る舞いを記述する
このアプリ仕様とも言える『振る舞い』をフィーチャーファイルに記述します。
まずはHappyPathとして最初の振る舞いを記述します。
今回はGherkinフォーマットを日本語キーワードで記述します。
# language: ja
機能: メッセージAPI
シナリオ: HappyPath
前提 ポート"7071"でサーバーが起動していること
もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている:
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
1.3. 実行してみる
実行は先ほど設定を追加したhatch経由で行います。
hatch run integration:all
機能: メッセージAPI # features/message_api.feature:3
シナリオ: HappyPath # features/message_api.feature:5
前提 ポート"7071"でサーバーが起動していること # None
もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # None
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # None
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
Failing scenarios:
features/message_api.feature:5 HappyPath
0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 3 undefined
Took 0m0.000s
You can implement step definitions for undefined steps with these snippets:
@given(u'ポート"7071"でサーバーが起動していること')
def step_impl(context):
raise NotImplementedError(u'STEP: Given ポート"7071"でサーバーが起動していること')
@when(u'"POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する')
def step_impl(context):
raise NotImplementedError(u'STEP: When "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する')
@then(u'ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている')
def step_impl(context):
raise NotImplementedError(u'STEP: Then ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている')
まだステップ(テストを実行するコード)を実装していないので、メソッドの雛形が表示されます。
これを元に実装を進めます。
1.4. 基本的なステップを実装する
前項で必要となった3つのステップを実装します。
実装は features/steps/steps.py
ファイルを新たに作成して下記の内容を記述していきます。
import json
import socket
import requests
from behave import given, then, when
@given('ポート"{port}"でサーバーが起動していること')
def check_localserver(context, port):
try:
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", int(port)))
client.close()
context.req_port = port
except ConnectionRefusedError:
assert False, f"Cannot connect to 127.0.0.1:{port}"
@when('"{method}"メソッドで"{path}"にリクエストを実行する')
def exec_http_request(context, method, path):
context.response = requests.request(
method.upper(),
f"http://localhost:{context.req_port}{path}",
)
@then('ステータスコードが"{status}"で、レスポンスボディが下記の形になっている')
def assert_response(context, status):
res_status = context.response.status_code
expected_json = json.loads(context.text)
res_json = context.response.json()
assert int(status[0:3]) == res_status
assert expected_json == res_json
ステップのパラメータは {port}
のようにマッピングを書いておくことで、ステップメソッドの引数として受け取ることができます。
またヒアドキュメントは context.text
で受け取ることができます。
ステップを跨いだ情報の受け渡しも context
経由で行います。今回の例では context.req_port
や context.response
がこれに当たります。
1.5. 改めてテストを実行する
前項で実装したステップを使ってテストを実行していきます。
そのためにまずはテスト対象のAzure Functinosを、別ターミナルで起動します。
func start
Found Python version 3.11.6 (python3).
Azure Functions Core Tools
Core Tools Version: 4.0.5455 Commit hash: N/A (64-bit)
Function Runtime Version: 4.27.5.21554
[2024-01-04T15:30:51.211Z] Customer packages not in sys path. This should never happen!
[2024-01-04T15:30:51.762Z] Worker process started and initialized.
Functions:
MyHttpTrigger: http://localhost:7071/api/MyHttpTrigger
For detailed output, run func with --verbose flag.
[2024-01-04T15:30:56.711Z] Host lock lease acquired by instance ID '0000000000000000000000007DB202B9'.
そして改めてテストを実行します。
hatch run integration:all
機能: メッセージAPI # features/message_api.feature:3
シナリオ: HappyPath # features/message_api.feature:5
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.001s
もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:27 0.033s
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:38 0.000s
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
3 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.034s
最後の4行から、今度はテストが成功したことがわかります。
1.5. テストを拡充させる
続けて下記のように features/message_api.feature
を追記します。
# language: ja
機能: メッセージAPI
シナリオ: HappyPath
前提 ポート"7071"でサーバーが起動していること
もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている:
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
+ シナリオ: リクエストパラメータにnameを含んでいるとき、レスポンスにnameの値が含まれている
+ 前提 ポート"7071"でサーバーが起動していること
+ もし リクエストパラメータに下記を設定する:
+ | key | value |
+ | name | HOGE |
+ かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する
+ ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている:
+ """
+ {"message": "Hello, HOGE. This HTTP triggered function executed successfully."}
+ """
+
+ シナリオ: リクエストボディにJSON形式でnameを含んでいるとき、レスポンスにnameの値が含まれている
+ 前提 ポート"7071"でサーバーが起動していること
+ もし Content-Typeに"application/json"を、リクエストボディに下記を設定する:
+ """
+ {"name": "FUGA"}
+ """
+ かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する
+ ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている:
+ """
+ {"message": "Hello, FUGA. This HTTP triggered function executed successfully."}
+ """
+
+ シナリオ: リクエストボディに不完全なJSON形式を含んでいるとき、リクエストボディは無視される
+ 前提 ポート"7071"でサーバーが起動していること
+ もし Content-Typeに"application/json"を、リクエストボディに下記を設定する:
+ """
+ {"name": "FUGA"
+ """
+ かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する
+ ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている:
+ """
+ {"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
+ """
+
その後、改めてテストを実行します。
hatch run integration:all
機能: メッセージAPI # features/message_api.feature:3
シナリオ: HappyPath # features/message_api.feature:5
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.001s
もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:27 0.009s
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:38 0.000s
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
シナリオ: リクエストパラメータにnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:13
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s
もし リクエストパラメータに下記を設定する # None
| key | value |
| name | HOGE |
かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # None
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # None
"""
{"message": "Hello, HOGE. This HTTP triggered function executed successfully."}
"""
シナリオ: リクエストボディにJSON形式でnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:24
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s
もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # None
"""
{"name": "FUGA"}
"""
かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # None
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # None
"""
{"message": "Hello, FUGA. This HTTP triggered function executed successfully."}
"""
シナリオ: リクエストボディに不完全なJSON形式を含んでいるとき、リクエストボディは無視される # features/message_api.feature:36
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s
もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # None
"""
{"name": "FUGA"
"""
かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # None
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # None
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
Failing scenarios:
features/message_api.feature:13 リクエストパラメータにnameを含んでいるとき、レスポンスにnameの値が含まれている
features/message_api.feature:24 リクエストボディにJSON形式でnameを含んでいるとき、レスポンスにnameの値が含まれている
features/message_api.feature:36 リクエストボディに不完全なJSON形式を含んでいるとき、リクエストボディは無視される
0 features passed, 1 failed, 0 skipped
1 scenario passed, 3 failed, 0 skipped
6 steps passed, 0 failed, 6 skipped, 3 undefined
Took 0m0.011s
You can implement step definitions for undefined steps with these snippets:
@when(u'リクエストパラメータに下記を設定する')
def step_impl(context):
raise NotImplementedError(u'STEP: When リクエストパラメータに下記を設定する')
@when(u'Content-Typeに"application/json"を、リクエストボディに下記を設定する')
def step_impl(context):
raise NotImplementedError(u'STEP: When Content-Typeに"application/json"を、リクエストボディに下記を設定する')
今回は2つのステップが不足していることがわかりました。
(前回実装したステップはそのまま使いまわせます。)
ステップファイルを下記のように更新します。
import json
import socket
import requests
from behave import given, then, when
@given('ポート"{port}"でサーバーが起動していること')
def check_localserver(context, port):
try:
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", int(port)))
client.close()
context.req_port = port
except ConnectionRefusedError:
assert False, f"Cannot connect to 127.0.0.1:{port}"
+ @when('Content-Typeに"{content_type}"を、リクエストボディに下記を設定する')
+ def exec_set_content_to_req_header_and_body(context, content_type):
+ if "req_headers" not in context:
+ context.req_headers = dict()
+ context.req_headers["Content-Type"] = content_type
+ context.req_body_str = context.text
+
+
+ @when("リクエストパラメータに下記を設定する")
+ def exec_set_req_params(context):
+ context.req_params = {row["key"]: row["value"] for row in context.table}
+
+
@when('"{method}"メソッドで"{path}"にリクエストを実行する')
def exec_http_request(context, method, path):
context.response = requests.request(
method.upper(),
f"http://localhost:{context.req_port}{path}",
+ params=context.req_params if "req_params" in context else None,
+ headers=context.req_headers if "req_headers" in context else None,
+ data=context.req_body_str if "req_body_str" in context else None,
)
@then('ステータスコードが"{status}"で、レスポンスボディが下記の形になっている')
def assert_response(context, status):
assert int(status[0:3]) == context.response.status_code
expected_json = json.loads(context.text)
res_json = context.response.json()
print(expected_json)
print(res_json)
assert expected_json == res_json
上記で扱っているように、表形式で渡されたデータは context.table
で受け取ることができ、for文で回して扱うことができます。
そして再度インテグレーションテストを実行します。
hatch run integration:all
機能: メッセージAPI # features/message_api.feature:3
シナリオ: HappyPath # features/message_api.feature:5
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s
もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:27 0.010s
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:38 0.000s
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
シナリオ: リクエストパラメータにnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:13
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s
もし リクエストパラメータに下記を設定する # features/steps/steps.py:48 0.000s
| key | value |
| name | HOGE |
かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:27 0.006s
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:38 0.000s
"""
{"message": "Hello, HOGE. This HTTP triggered function executed successfully."}
"""
シナリオ: リクエストボディにJSON形式でnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:24
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s
もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # features/steps/steps.py:19 0.000s
"""
{"name": "FUGA"}
"""
かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:27 0.005s
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:38 0.000s
"""
{"message": "Hello, FUGA. This HTTP triggered function executed successfully."}
"""
シナリオ: リクエストボディに不完全なJSON形式を含んでいるとき、リクエストボディは無視される # features/message_api.feature:36
前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s
もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # features/steps/steps.py:19 0.000s
"""
{"name": "FUGA"
"""
かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:27 0.004s
ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:38 0.000s
"""
{"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."}
"""
1 feature passed, 0 failed, 0 skipped
4 scenarios passed, 0 failed, 0 skipped
15 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.027s
今度は全てのテストがパスしました。
1.6. ここまでの作業を記録する
ここまで作業した内容をGitリポジトリに記憶します。
git add .
git commit -m "behaveを使ったインテグレーションテストを作成"
[main bb614a8] behaveを使ったインテグレーションテストを作成
2 files changed, 107 insertions(+), 12 deletions(-)
create mode 100644 tests/test_function_app.py
まとめ
これで既存の振る舞い(仕様)をfeatureにまとめつつ、インテグレーションテストとしても活用する仕組みを作ることができました。
Gherkinフォーマットで記述したfeatureは他のプログラミング言語でも使える共通仕様で、可読性はかなり高い方なので、覚えておくと便利です。
次回はBlueprintを使ったAzure Functionsのリファクタリングについて書こうと思います。
次回の記事はこちら:
Discussion