😺

behaveを使ってAzure Functions(Python版)でBDD(振る舞い駆動開発)を始める

2024/01/08に公開

この記事はbehaveを使ってAzure Functions(Python版)でBDD(振る舞い駆動開発)を始めるためのメモです。

Azure FunctionsはちょっとしたPythonAPIを動かすのにちょうど良いFaaS(Function as a Service)です。

今回はインテグレーションテスト環境をbehaveを使って整えます。

behaveはBDD(振る舞い駆動開発)を行う上で有効なライブラリです。

特徴としてはGherkinフォーマットで振る舞いを記述し、pythonで実行するコードを記述するため、仕様と実装を分けることができます。

これにより役割分担ができるだけでなく、仕様の可読性も格段に上がります。

ここでは、過去に作成したAzure Functionsに対してbehaveを使ってインテグレーションテストを書き進めていきます。

過去に作成したAzure Functionsについては下記の通り。

  1. 基本編
    1. AzureStaticWebAppをReact+AzureFunctions(Python)で作って、ローカルで動かしてみる
  2. Azure Functions編
    1. Azure Functions(Python版)の試験をpytestで行う
    2. hatchでAzure Functions(Python版)のLintチェックをしながらリファクタリングする

今回はAzure Functions編のため、作業ディレクトリは下記になります。

~/devel/sandbox_azure_functions

1. hatchにインテグレーションテストの設定を追加し、実行する

1.1. pyproject.tomlにbehave用の設定を記入し、環境を整える

pyproject.tomlファイルの末尾に、behave用の設定を追記します。

pyproject.toml

  〜〜〜〜〜(前略)〜〜〜〜〜

    ".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フォーマットを日本語キーワードで記述します。

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."}
        """

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 ファイルを新たに作成して下記の内容を記述していきます。

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_portcontext.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 を追記します。

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つのステップが不足していることがわかりました。
(前回実装したステップはそのまま使いまわせます。)

ステップファイルを下記のように更新します。

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('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