Azure Functions(Python版)の試験をpytestで行う
この記事はAzure Functions(Python版)の試験をpytestで行うためのメモです。
Azure FunctionsはちょっとしたPythonAPIを動かすのにちょうど良いFaaS(Function as a Service)です。
ここでは、前回作成したAzure Functionsに対してテストを書いて実行してみます。
もしこの記事から読まれた場合は、前回記事の『1. Azure FunctionsでHTTPトリガーの関数アプリを実装する』を完了しておくと、スムーズに入れると思います。
今回の作業ディレクトリは、下記になります。
~/devel/sandbox_azure_functions
1. hatchによるプロジェクト構成
今回もpytestを使ってテストを書いていくのですが、Azure Functionsの特性上なるべくrequirements.txtに不要なものを入れないようにしたいので、hatchとpyproject.tomlを使って環境構築をしていきます。
1.1. Python仮想環境の作成
前回はfuncコマンドで全て賄ってきたため、仮想環境を作成していませんでした。
今回はhatchを始めライブラリをいくつか入れていくので、Python仮想環境を作成します。
- venvを作成します実行コマンド
python3.11 -m venv .venv
- 作成したvenvを適用します実行コマンド
source .venv/bin/activate
- 仮想環境のpipコマンドをバージョンアップします実行コマンド
pip install --upgrade pip
出力結果Requirement already satisfied: pip in ./.venv/lib/python3.11/site-packages (23.3.1) Collecting pip Using cached pip-23.3.2-py3-none-any.whl.metadata (3.5 kB) Using cached pip-23.3.2-py3-none-any.whl (2.1 MB) Installing collected packages: pip Attempting uninstall: pip Found existing installation: pip 23.3.1 Uninstalling pip-23.3.1: Successfully uninstalled pip-23.3.1 Successfully installed pip-23.3.2
ここまでの作業でPython仮想環境が作成できました。
1.2. hatchによるプロジェクト設定ファイルの作成
次に前項で作成したPython仮想環境にhatchをインストールし、pyproject.tomlを設定していきます。
- hatchのインストール実行コマンド
pip install hatch
出力結果Collecting hatch Downloading hatch-1.9.1-py3-none-any.whl.metadata (5.2 kB) Collecting click>=8.0.6 (from hatch) Downloading click-8.1.7-py3-none-any.whl.metadata (3.0 kB) 〜〜〜〜〜(中略)〜〜〜〜〜 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 55.8/55.8 kB 5.7 MB/s eta 0:00:00 Installing collected packages: trove-classifiers, ptyprocess, distlib, zstandard, zipp, tomlkit, tomli-w, sniffio, shellingham, pygments, pluggy, platformdirs, pexpect, pathspec, packaging, more-itertools, mdurl, idna, h11, filelock, editables, click, certifi, virtualenv, userpath, markdown-it-py, jaraco.classes, importlib-metadata, hyperlink, httpcore, hatchling, anyio, rich, keyring, httpx, hatch Successfully installed anyio-4.2.0 certifi-2023.11.17 click-8.1.7 distlib-0.3.8 editables-0.5 filelock-3.13.1 h11-0.14.0 hatch-1.9.1 hatchling-1.21.0 httpcore-1.0.2 httpx-0.26.0 hyperlink-21.0.0 idna-3.6 importlib-metadata-7.0.1 jaraco.classes-3.3.0 keyring-24.3.0 markdown-it-py-3.0.0 mdurl-0.1.2 more-itertools-10.1.0 packaging-23.2 pathspec-0.12.1 pexpect-4.9.0 platformdirs-4.1.0 pluggy-1.3.0 ptyprocess-0.7.0 pygments-2.17.2 rich-13.7.0 shellingham-1.5.4 sniffio-1.3.0 tomli-w-1.0.0 tomlkit-0.12.3 trove-classifiers-2023.11.29 userpath-1.9.1 virtualenv-20.25.0 zipp-3.17.0 zstandard-0.22.0
- pyproject.tomlの作成実行コマンド
hatch new --init sandbox_azure_functions
出力結果Description []:
- ここで詳細説明を書くフィールドがあるが、今回は
Enterキー
でスキップします
出力結果(続き)Wrote: pyproject.toml
- ここで詳細説明を書くフィールドがあるが、今回は
これで pyproject.toml
が作成されました。
ここで一旦Gitリポジトリに記録しておきます。
-
pyproject.toml
の初期状態を記録しておきます実行コマンドgit add pyproject.toml git commit -m "hatchでpyproject.tomlを作成"
出力結果[main a19d816] hatchでpyproject.tomlを作成 1 file changed, 81 insertions(+) create mode 100644 pyproject.toml
1.3. Azure Functionsに合わせてpyproject.tomlを更新する
次にAzure Functionsにあわせて pyproject.toml
を更新していきます。
VisualStudio Codeで pyproject.toml
を開いて下記の作業をしていきます。
- 実行環境で依存するライブラリを追記しますpyproject.toml
〜〜〜〜〜(前略)〜〜〜〜〜 "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] - dependencies = [] + dependencies = [ + 'azure-functions' + ] [project.urls] Documentation = "https://github.com/unknown/sandbox-azure-functions-python#readme" Issues = "https://github.com/unknown/sandbox-azure-functions-python/issues" Source = "https://github.com/unknown/sandbox-azure-functions-python" 〜〜〜〜〜(後略)〜〜〜〜〜
- バージョン記述ファイルの場所を変更しますpyproject.toml
〜〜〜〜〜(前略)〜〜〜〜〜 Documentation = "https://github.com/unknown/sandbox-azure-functions-python#readme" Issues = "https://github.com/unknown/sandbox-azure-functions-python/issues" Source = "https://github.com/unknown/sandbox-azure-functions-python" [tool.hatch.version] - path = "src/sandbox_azure_functions_python/__about__.py" + path = "__about__.py" [tool.hatch.envs.default] dependencies = [ "coverage[toml]>=6.5", "pytest", 〜〜〜〜〜(後略)〜〜〜〜〜
- テストカバレッジ用の設定を変更しますpyproject.toml
〜〜〜〜〜(前略)〜〜〜〜〜 ] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" test-cov = "coverage run -m pytest {args:tests}" + cov-html = "coverage html" cov-report = [ "- coverage combine", - "coverage report", + "coverage report -m", ] cov = [ "test-cov", "cov-report", ] 〜〜〜〜〜(中略)〜〜〜〜〜 [tool.hatch.envs.types] dependencies = [ "mypy>=1.0.0", ] [tool.hatch.envs.types.scripts] - check = "mypy --install-types --non-interactive {args:src/sandbox_azure_functions tests}" + check = "mypy --install-types --non-interactive {args:function_app.py tests}" [tool.coverage.run] + source = ["."] - source_pkgs = ["sandbox_azure_functions_python", "tests"] + source_pkgs = [] - branch = true parallel = true omit = [ - "src/sandbox_azure_functions_python/__about__.py", + "__about__.py", + "tests/*", ] [tool.coverage.report] exclude_lines = [ "no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] 〜〜〜〜〜(後略)〜〜〜〜〜
- ファイルの末尾にテスト用の設定を追記しますpyproject.toml
〜〜〜〜〜(前略)〜〜〜〜〜 [tool.coverage.report] exclude_lines = [ "no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] + + [tool.hatch.build.targets.wheel] + packages = ["functions_app.py"] + + [tool.pytest.ini_options] + pythonpath = "." + testpaths = ["tests"] +
ここまでの作業でpyproject.toml全体の変更箇所は下記の通りになっています。
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "sandbox-azure-functions-python"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [
{ name = "Developer-1", email = "dev1@example.com" },
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
- dependencies = []
+ dependencies = [
+ 'azure-functions'
+ ]
[project.urls]
Documentation = "https://github.com/unknown/sandbox-azure-functions-python#readme"
Issues = "https://github.com/unknown/sandbox-azure-functions-python/issues"
Source = "https://github.com/unknown/sandbox-azure-functions-python"
[tool.hatch.version]
- path = "src/sandbox_azure_functions_python/__about__.py"
+ path = "__about__.py"
[tool.hatch.envs.default]
dependencies = [
"coverage[toml]>=6.5",
"pytest",
]
[tool.hatch.envs.default.scripts]
test = "pytest {args:tests}"
test-cov = "coverage run -m pytest {args:tests}"
+ cov-html = "coverage html"
cov-report = [
"- coverage combine",
- "coverage report",
+ "coverage report -m",
]
cov = [
"test-cov",
"cov-report",
]
[[tool.hatch.envs.all.matrix]]
python = ["3.8", "3.9", "3.10", "3.11", "3.12"]
[tool.hatch.envs.types]
dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
- check = "mypy --install-types --non-interactive {args:src/sandbox_azure_functions_python tests}"
+ check = "mypy --install-types --non-interactive {args:function_app.py tests}"
[tool.coverage.run]
+ source = ["."]
- source_pkgs = ["sandbox_azure_functions_python", "tests"]
+ source_pkgs = []
branch = true
parallel = true
omit = [
- "src/sandbox_azure_functions_python/__about__.py",
+ "__about__.py",
+ "tests/*",
]
- [tool.coverage.paths]
- sandbox_azure_functions_python = ["src/sandbox_azure_functions_python", "*/ sandbox-azure-functions-python/src/sandbox_azure_functions_python"]
- tests = ["tests", "*/sandbox-azure-functions-python/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
+
+ [tool.hatch.build.targets.wheel]
+ packages = ["functions_app.py"]
+
+ [tool.pytest.ini_options]
+ pythonpath = "."
+ testpaths = ["tests"]
+
1.5. pyproject.tomlに沿って必要なファイルを作成・移動する
- README.mdの作成実行コマンド
echo "#sandbox_azure_functions_python" > README.md
- バージョンファイルの作成実行コマンド
echo '__version__ = "0.0.1"' > __about__.py
- テストフォルダの作成実行コマンド
mkdir tests touch tests/__init__.py
1.6. ignoreファイルの追記
- .gitignoreを下記のように更新します.gitignore
〜〜〜〜〜(前略)〜〜〜〜〜 __queuestorage__ __azurite_db*__.json + + .coverage* + .*_cache + htmlcov + .DS_Store +
- .funcignoreを下記のように更新します.funcignore
.venv + + .coverage* + .*_cache + htmlcov + .vscode + dist + htmlcov + tests + .coverage* + .flake8 + local.settings* + pyproject.toml + README.md + .DS_Store +
ここまでで初期設定は完了です。
1.7. 試しにテストを実行してみる
前項までで作成した環境でテストを実行してみます。
- テストの実行実行コマンド
hatch run test
出力結果=========================== test session starts ============================ platform darwin -- Python 3.10.13, pytest-7.4.3, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 0 items ========================== no tests ran in 0.00s ===========================
テストが見つからず終了したのもの、実行エラーは起きませんでした。
1.8. ここまでの作業を記録する
ここまで作業した内容をGitリポジトリに記憶します。
- 作業内容をコミットする実行コマンド
git add . git commit -m "プロジェクトに合わせてpyproject.tomlを調整"
出力結果[main 6a02cb5] プロジェクトに合わせてpyproject.tomlを調整 7 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 .flake8 create mode 100644 README.md create mode 100644 __about__.py create mode 100644 tests/__init__.py
2. 既存のfunction_app.pyに対するテストの作成
次にいよいよ既存のfunction_app.pyに対するテストを書いていきます。
既存の function_app.py
は、/api/MyHttpTrigger
にAPIコールしたとき、ステータスコード200OKと共に下記のJSONを返してくるAPIです。
{ "message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response." }
また、リクエストパラメータにnameを設定してあげると、下記のようにレスポンスが変化します。
{ "message": "Hello, {name}. This HTTP triggered function executed successfully." }
本来のテスト駆動開発ではテストから記述するため、最初はRED工程ですが、今回は実装がすでにあるためGREENであることを確認していきます。
2.1. pytestを使ってユニットテストを作成する
- テストファイルを
tests/test_function_app.py
に下記の内容で作成しますtests/test_function_app.pyimport json from unittest import TestCase import azure.functions as func from function_app import MyHttpTrigger class TestFunctionApp(TestCase): def test_without_params(self): req = func.HttpRequest( method="POST", body=json.dumps({}).encode(), url="/api/MyHttpTrigger" ) func_call = MyHttpTrigger.build().get_user_function() resp = func_call(req) self.assertEqual(resp.status_code, 200)
これは、APIリクエストに対するレスポンスのステータスコードが200であることを確認する内容です。
- テストを実行します実行コマンド
hatch run test
出力結果=========================== test session starts ============================ platform darwin -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 1 item tests/test_function_app.py . [100%] ============================ 1 passed in 0.07s =============================
テストが検出され、無事に成功していることが確認できました。
次にレスポンスボディを評価します。
- テストファイルを
tests/test_function_app.py
に下記の内容で追記しますtests/test_function_app.pyimport json from unittest import TestCase import azure.functions as func from function_app import MyHttpTrigger class TestFunctionApp(TestCase): def test_without_params(self): req = func.HttpRequest( method="POST", body=json.dumps({}).encode(), url="/api/MyHttpTrigger" ) func_call = MyHttpTrigger.build().get_user_function() resp = func_call(req) self.assertEqual(resp.status_code, 200) + + expected_message = ( + "This HTTP triggered function executed successfully. Pass a name in " + "the query string or in the request body for a personalized response." + ) + self.assertDictEqual( + json.loads(resp.get_body()), + {"message": expected_message}, + ) +
これは、APIリクエストに対するレスポンスボディが、下記のJSONであることを確認する内容です。
{ "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 test
出力結果=========================== test session starts ============================ platform darwin -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 1 item tests/test_function_app.py . [100%] ============================ 1 passed in 0.07s =============================
テストが成功しました。
次にリクエストパラメータnameを指定するテストケースを書いてみます。
- テストファイルを
tests/test_function_app.py
に下記の内容で追記しますtests/test_function_app.pyimport json from unittest import TestCase import azure.functions as func from function_app import MyHttpTrigger class TestFunctionApp(TestCase): def test_without_params(self): req = func.HttpRequest( method="POST", body=json.dumps({}).encode(), url="/api/MyHttpTrigger" ) func_call = MyHttpTrigger.build().get_user_function() resp = func_call(req) self.assertEqual(resp.status_code, 200) expected_message = ( "This HTTP triggered function executed successfully. Pass a name in " "the query string or in the request body for a personalized response." ) self.assertDictEqual( json.loads(resp.get_body()), {"message": expected_message}, ) + def test_with_body(self): + req = func.HttpRequest( + method="POST", + body=json.dumps({"name": "HOGE"}).encode(), + url="/api/MyHttpTrigger", + ) + func_call = MyHttpTrigger.build().get_user_function() + resp = func_call(req) + + expected_status = 200 + expected_message = ( + "Hello, HOGE. This HTTP triggered function executed successfully." + ) + self.assertEqual(resp.status_code, expected_status) + self.assertDictEqual( + json.loads(resp.get_body()), + {"message": expected_message}, + ) +
これは、APIリクエストボディにname=HOGEを設定したパターンで、それに対応するレスポンスボディが下記のJSONであることを確認する内容です。
{ "message": "Hello, HOGE. This HTTP triggered function executed successfully." }
- テストを実行します実行コマンド
hatch run test
出力結果=========================== test session starts ============================ platform darwin -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 2 items tests/test_function_app.py .. [100%] ============================ 2 passed in 0.06s =============================
テストが成功しました。
最後は別のパターンとして、リクエストパラメータにname=FUGAを指定したパターンを追加します。
-
テストファイルを
tests/test_function_app.py
に下記の内容で追記しますtests/test_function_app.py〜〜〜〜〜(前略)〜〜〜〜〜 self.assertEqual(resp.status_code, expected_status) self.assertDictEqual( json.loads(resp.get_body()), {"message": expected_message}, ) + def test_with_params(self): + req = func.HttpRequest( + method="POST", + body=json.dumps({}).encode(), + url="/api/MyHttpTrigger", + params={"name": "FUGA"}, + ) + func_call = MyHttpTrigger.build().get_user_function() + resp = func_call(req) + + expected_status = 200 + expected_message = ( + "Hello, FUGA. This HTTP triggered function executed successfully." + ) + self.assertEqual(resp.status_code, 200) + self.assertDictEqual( + json.loads(resp.get_body()), + {"message": expected_message}, + ) +
-
テストを実行します
実行コマンドhatch run test
出力結果=========================== test session starts ============================ platform darwin -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 3 items tests/test_function_app.py ... [100%] ============================ 3 passed in 0.07s =============================
テストが成功しました。
ここまでで3つのパターンのテストが完了しました。
2.2. テストカバレッジを取得して不足している点を洗い出す
テストカバレッジを取得することで、これまで作成したテストで全ての行を網羅できているのか確認できます。
- カバレッジを取得します実行コマンド
hatch run cov
出力結果cmd [1] | coverage run -m pytest tests =========================== test session starts ============================ platform darwin -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 3 items tests/test_function_app.py ... [100%] ============================ 3 passed in 0.09s ============================= cmd [2] | - coverage combine Combined data file .coverage.M1.local.53357.XLUWXBcx Combined data file .coverage.M1.local.53393.XPobppwx cmd [3] | coverage report -m Name Stmts Miss Branch BrPart Cover Missing ------------------------------------------------------------- function_app.py 18 2 6 0 92% 16-17 ------------------------------------------------------------- TOTAL 18 2 6 0 92%
この結果を見る限り、 function_app.py
の16行目から17行目に関するテストが不足しているようです。
より詳細な情報を確認たい場合は、htmlファイルに出力したものを見ると便利です。
- カバレッジレポートをHTMLで出力する実行コマンド
hatch run cov-html
出力結果Wrote HTML report to htmlcov/index.html
結果はhtmlcovフォルダ内に出力されるので、ブラウザで開いて内容を確認します。
- カバレッジレポートをブラウザで開く実行コマンド
open htmlcov/index.html
すると下記のような画面が表示されます。
ここで、一覧表内に表示されている function_app.py
をクリックすると、下記のようにコードレベルでカバレッジを確認できます。
ここで、『req.get_json()
が ValueError
をthrowした際のテスト』が不足していることを確認できました。
そのため、意図的にValueErrorになるようなリクエストボディを入力して、この不足ケースをテストしていきます。
-
テストファイルを
tests/test_function_app.py
に下記の内容で追記しますtests/test_function_app.py〜〜〜〜〜(前略)〜〜〜〜〜 self.assertEqual(resp.status_code, 200) self.assertDictEqual( json.loads(resp.get_body()), {"message": expected_message}, ) + def test_with_value_error_data(self): + req = func.HttpRequest( + method="POST", + body='{'.encode(), + url="/api/MyHttpTrigger", + ) + func_call = MyHttpTrigger.build().get_user_function() + resp = func_call(req) + expected_status = 200 + expected_message = ( + "This HTTP triggered function executed successfully. Pass a name in " + "the query string or in the request body for a personalized response." + ) + self.assertEqual(resp.status_code, expected_status) + self.assertDictEqual( + json.loads(resp.get_body()), + {"message": expected_message}, + ) +
-
テストと合わせてカバレッジの取得します
実行コマンドhatch run cov
出力結果cmd [1] | coverage run -m pytest tests =========================== test session starts ============================ platform darwin -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 4 items tests/test_function_app.py .... [100%] ============================ 4 passed in 0.09s ============================= cmd [2] | - coverage combine Combined data file .coverage.M1.local.56110.XVxUrLCx cmd [3] | coverage report -m Name Stmts Miss Branch BrPart Cover Missing ------------------------------------------------------------- function_app.py 18 0 6 0 100% ------------------------------------------------------------- TOTAL 18 0 6 0 100%
Missingが消えて、カバレッジが100%になりました。
これで全ての工程が完了したので、スタートラインであるGREENであることの確認まで完了しました。
2.3. ここまでの作業を記録する
ここまで作業した内容をGitリポジトリに記憶します。
- 作業内容をコミットする実行コマンド
git add . git commit -m "function_appに対するテストを作成"
出力結果[main b614a8b] function_appに対するテストを作成 2 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 tests/test_function_app.py
まとめ
ここまでの実装で、Azure Functions(Python版)の試験をpytestで行うことができました。
加えてカバレッジの可視化までできるようになったので、ここでやっとテスト駆動開発のベースができたと言えるでしょう。
次回はhatchの上でLintチェックを行う環境を整え、リファクタリングを行っていきます。
次回の記事はこちら:
Discussion