この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
Pythonベースで作成されたAWS Lambdaのハンドラリファクタリングをしていました。個人的にとにかく面倒に感じたのはevent
とcontext
のバリデーションでした。値のチェックによる行数の肥大化、及び一時変数の増加がつきものになります。
チェック用コードの関数化、及び関数のdecorator化とまで進めてみましたが、中々頭を捻ったのがdecoretorのテストです。実際のコード及びテストを元にした実装例を交えて、考慮した部分をまとめてみました。
decoretorのコード
テスト対象のdecoratorコードは以下の通り。実際のコードから最低限のロジックを抜粋しています。decorator化にはpartial
を使う手もありましたが、wraps
はラップされた関数のみを引数に指定すれば良い点がメリットです。
import json
from argparse import ArgumentParser
import datetime
from functools import wraps
MESSAGE_INVALID_DATE = '年月の形式が異なります'
def validate_date(yyyymm):
try:
datetime.datetime.strptime(f"{yyyymm}01", "%Y%m01")
return yyyymm
except ValueError:
raise argparse.ArgumentTypeError(f"{MESSAGE_INVALID_DATE} - {yyyymm}")
def validate_id(id):
if not bool(re.search(r'^[0-9]{12}$', id)):
return
return id
def parse_arguments(func):
@wraps(func)
def wrapper(event, context, *args, **kwds):
validate_data = []
for record in event['Records']:
info = json.loads(record['body'])
cmd_params = info['command_params']
parser = ArgumentParser()
parser.add_argument('yyyymm', help='年月', type=validate_date)
parser.add_argument('acc_ids', help='アカウントID',
type=validate_id, nargs='+')
data = parser.parse_args(cmd_params)
validate_data.append({
'yyyymm': data.yyyymm,
'acc_ids': data.acc_ids
})
kwds['validate_data'] = validate_data
return func(event, context, *args, **kwds)
return wrapper
実際にdecoratorとして使う場合は、以下のようになります。
from ... import parse_arguments
@parse_arguments
def invoke(*args, **kwargs):
....
decoratorのテストを追加する
decoratorとして使った状態でのテストを追加していきます。wrapperとしてdecoratorが素のパラメータを受け取り、チェックし、wrappされた関数に渡します。本来のハンドラと異なり、あくまでもvalidate後のデータ比較のため、今回の関数は何もせずに直接返す構成にしました。
import pytest
import json
@pytest.mark.parametrize('args, result', [
('202101', True),
('202121', False)
])
def test_validate_date(args, result):
if result:
assert validate_date(args) == args
else:
with pytest.raises(argparse.ArgumentTypeError):
validate_date(args)
@pytest.mark.parametrize('args, result, valid_id', [
('123456789012', True, '123456789012'),
('123456789', False, None),
('1234567890123', False, None),
])
def test_validate_ids(args, result, valid_id):
assert validate_id(args) == valid_id
@pytest.mark.parametrize('arg, context, params', [
({
"Records": [{
"body": json.dumps({
"command_params": [
"202101", "123456789023", "123456789012", "123456789023"
]
})
}]
}, {}, [{
'yyyymm': '202101',
'acc_ids': ["123456789023", "123456789012", "123456789023"],
}])
])
def test_parse_arguments(arg, context, params):
@parse_arguments
def test(*args, **kwargs):
return kwargs['validate_data']
assert test(arg, context) == params
parse_arguments
そのものに対してのアサーションも考えられますが、decorator動作そのものはPythonライブラリ側で担保されているものと考え、今回は省きました。
テストの実行
テスト実施後の結果です。一部加工しています。
% cat pytest.ini
[pytest]
log_cli = 1
log_cli_level = ERROR
testpaths = ./tests/ ./lambda_function/
python_files = test_*.py
python_paths = ./lambda_function/
% pipenv run pytest --flake8 --cache-clear -vv --cov=lambda_function
Courtesy Notice: Pipenv found itself running within a virtual environment, so it will automatically use that environment, instead of creating its own for any project. You can set PIPENV_IGNORE_VIRTUALENVS=1 to force pipenv to ignore that environment and create its own instead. You can set PIPENV_VERBOSITY=-1 to suppress this warning.
================================================================== test session starts ====================================================================
platform darwin -- Python 3.7.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1 -- .venv/bin/python
cachedir: .pytest_cache
rootdir: ., configfile: pytest.ini, testpaths: ./tests/, ./lambda_function/
plugins: flake8-1.0.7, env-0.6.2, cov-2.11.1, pythonpath-0.7.3
collected 42 items
tests/test_decorator.py::FLAKE8 PASSED
tests/test_decorator.py::test_validate_date[202101-True] PASSED
tests/test_decorator.py::test_validate_date[202121-False] PASSED
tests/test_decorator.py::test_validate_ids[123456789012-True-123456789012] PASSED
tests/test_decorator.py::test_validate_ids[123456789-False-None] PASSED
tests/test_decorator.py::test_validate_ids[1234567890123-False-None] PASSED
tests/test_decorator.py::test_parse_arguments[arg0-context0-params0] PASSED
---------- coverage: platform darwin, python 3.7.5-final-0 -----------
Name Stmts Miss Cover
----------------------------------------------------------------------
lambda_function/__init__.py 0 0 100%
lambda_function/task.py 38 0 100%
----------------------------------------------------------------------
TOTAL 38 0 100%
================================================================== 1 passed in 0.43s ======================================================================
あとがき
AWS Lambdaの引数がdict型の入れ子となっている場合や、値のチェックパターンが多岐に及ぶ場合、decorator化した関数で扱うメリットは大いにあります。逆にシンプルだとハンドラ内でのチェックで済ませるほうがメンテナンスコストも低く済みます。
コードそのものに対して求める精度にもよりますが、dict型の入れ子が連なったLambda関数に手を焼いている場合は検討してみることをおすすめします。