Secrets Manager に限らず、いつもやり方を忘れてしまうので備忘録として残しておく。
前提
事前準備
AWS アカウントで事前に Secrets を作成しておく。今回は例として、DB の接続情報を格納する。エンジンは MySQL を想定している。
Secret name は sample/pytest
とし、値は以下のようになっている。ちなみに password はこのブログのために用意した一時的なもの。
AWS CLI で取得すると以下の出力が取得できた。これで準備は完了。
$ aws secretsmanager get-secret-value --secret-id sample/pytest { "ARN": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:sample/pytest-xxxx", "Name": "sample/pytest", "VersionId": "024aedb2-0c51-43d1-be6d-e45f80ec0516", "SecretString": "{\"username\":\"admin\",\"password\":\"FLsr(uZoI@@Z+Aa?\",\"engine\":\"mysql\",\"host\":\"0.0.0.0\",\"port\":\"3306\",\"dbname\":\"pytest\"}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2023-04-21T21:18:04.919000+09:00" }
仮実装
まずはまっさらな状態からテストコードを書く。今回は Secrets Manager から SecretString を取得し、それを戻り値として dict 型で返す関数を書きたい。呼び出す Secrets Manager の API は GetSecretsValue を想定している。
モジュール名は get_secret_string
としよう。関数名も get_secret_string
とする。期待値は先ほど AWS CLI で叩いた結果の SecretString
の値を dict 型にする。テストコードは以下となった。本来 username や password などの機微な情報は環境変数から取得するようにすべきなのは理解している。
# test_get_secret_string.py def test_get_secret_string(): expected: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } secret_value: dict = get_secret_string() assert secret_value == expected
テストを実行する。まだモジュールも get_secret_string
関数も存在しないためインポートエラーとなりそうだ。
$ pytest ================================================================= test session starts ================================================================= platform darwin -- Python 3.9.12, pytest-7.1.3, pluggy-1.0.0 rootdir: /Users/enokawa/workspace/github.com/enokawa/pytest-sandbox/secrets_manager collected 1 item test_get_secret_string.py F [100%] ====================================================================== FAILURES ======================================================================= _______________________________________________________________ test_get_secret_string ________________________________________________________________ def test_get_secret_string(): expected: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } > secret_value: dict = get_secret_string() E NameError: name 'get_secret_string' is not defined test_get_secret_string.py:11: NameError =============================================================== short test summary info =============================================================== FAILED test_get_secret_string.py::test_get_secret_string - NameError: name 'get_secret_string' is not defined ================================================================== 1 failed in 0.02s ==================================================================
NameError だった。恥ずかしい。 get_secret_string
が定義されていないため FAILED となった。次に get_secret_string
モジュールと関数を作成する。関数では仮実装として None
を返すようにしよう。
# get_secret_string.py def get_secret_string() -> dict: return
テストモジュールで get_secret_string
モジュールの get_secret_string
関数をインポートする。これでテストを実行したら AssertionError となるはず。
# test_get_secret_string.py from get_secret_string import get_secret_string def test_get_secret_string(): expected: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } secret_value: dict = get_secret_string() assert secret_value == expected
$ pytest ================================================================= test session starts ================================================================= platform darwin -- Python 3.9.12, pytest-7.1.3, pluggy-1.0.0 rootdir: /Users/enokawa/workspace/github.com/enokawa/pytest-sandbox/secrets_manager collected 1 item test_get_secret_string.py F [100%] ====================================================================== FAILURES ======================================================================= _______________________________________________________________ test_get_secret_string ________________________________________________________________ def test_get_secret_string(): expected: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } secret_value: dict = get_secret_string() > assert secret_value == expected E AssertionError: assert None == {'dbname': 'pytest', 'engine': 'mysql', 'host': '0.0.0.0', 'password': 'FLsr(uZoI@@Z+Aa?', ...} test_get_secret_string.py:14: AssertionError =============================================================== short test summary info =============================================================== FAILED test_get_secret_string.py::test_get_secret_string - AssertionError: assert None == {'dbname': 'pytest', 'engine': 'mysql', 'host': '0.0.0.0',... ================================================================== 1 failed in 0.02s ==================================================================
想定通り AssertionError となった。これで TDD における "RED" には到達した。次に明白な実装をして "GREEN" にする。get_secret_string
関数の返り値を、テストコードで書いた expected
変数の値と同じにして、再度テストを実行する。GREEN になるはずだ。
# get_secret_string.py def get_secret_string() -> dict: value: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } return value
$ pytest ================================================================= test session starts ================================================================= platform darwin -- Python 3.9.12, pytest-7.1.3, pluggy-1.0.0 rootdir: /Users/enokawa/workspace/github.com/enokawa/pytest-sandbox/secrets_manager collected 1 item test_get_secret_string.py . [100%] ================================================================== 1 passed in 0.00s ==================================================================
やったね。これからリファクタリングしていく。参考までに、この時点でのディレクトリ構成を記載しておく。
$ tree . . ├── get_secret_string.py └── test_get_secret_string.py 0 directories, 2 files
GetSecretsValue APIを呼び出す
boto3 のドキュメントを参考に、get_secret_value
メソッドを呼び出す。
必須の引数は SecretId
のみで OK なので、作成済みの sample/pytest
を指定する。戻り値は dict で返却されるため、SecretString
キーの値を json.loads()
してあげればよさそうだ。
# get_secret_string.py import json import boto3 def get_secret_string() -> dict: client = boto3.client('secretsmanager') response = client.get_secret_value( SecretId='sample/pytest' ) secret_string: str = response["SecretString"] return json.loads(secret_string)
これで AWS Credential を仕込んだうえでテストを実行すれば通りそうな気がする。やってみる。
$ export AWS_PROFILE=enokawa $ pytest 中略 =============================================================== short test summary info =============================================================== FAILED test_get_secret_string.py::test_get_secret_string - botocore.exceptions.NoRegionError: You must specify a region. ================================================================== 1 failed in 2.30s ==================================================================
NoRegionError が出たので、SecretsManager Client の作成時にリージョンも指定する。これでいけるかな。
# get_secret_string.py import json import boto3 def get_secret_string() -> dict: client = boto3.client('secretsmanager', region_name="ap-northeast-1") response = client.get_secret_value( SecretId='sample/pytest' ) secret_string: str = response["SecretString"] return json.loads(secret_string)
$ pytest ================================================================= test session starts ================================================================= platform darwin -- Python 3.9.12, pytest-7.1.3, pluggy-1.0.0 rootdir: /Users/enokawa/workspace/github.com/enokawa/pytest-sandbox/secrets_manager collected 1 item test_get_secret_string.py . [100%] ================================================================== 1 passed in 0.27s ==================================================================
いけてそう。AWS Credential を指定しない状態でテストを実行すると認証エラーが出るはず。
$ unset AWS_PROFILE $ pytest 中略 =============================================================== short test summary info =============================================================== FAILED test_get_secret_string.py::test_get_secret_string - botocore.exceptions.NoCredentialsError: Unable to locate credentials ================================================================== 1 failed in 2.25s ==================================================================
想定通り NoCredentialsError
が出た。次に進む。
motoを使わずにモックできないのか?
pytest-mock でモックできないか試してみる。
# test_get_secret_string.py from get_secret_string import get_secret_string def test_get_secret_string(mocker): expected: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } mocker.patch("get_secret_string.get_secret_string", return_value=expected) secret_value: dict = get_secret_string() assert secret_value == expected
=============================================================== short test summary info =============================================================== FAILED test_get_secret_string.py::test_get_secret_string - botocore.exceptions.NoCredentialsError: Unable to locate credentials ================================================================== 1 failed in 3.62s ==================================================================
NoCredentialsError
が出た。ということは GetSecretsValue API が呼ばれたということになりそうだ。この理由はまだ分かっていなくて、理解したタイミングで別エントリに記載しようと思う。
motoでget_secret_valueをモックする
ようやく本題。Credential を指定しない状態でテストを実行し、PASS することをゴールとする。
moto のドキュメントから、 get_secret_value
のモックはできそうということが分かった。
ではテストコードを実装していく。mock_secretsmanager
decorator を利用すればモックできそうだ。
# test_get_secret_string.py from get_secret_string import get_secret_string from moto import mock_secretsmanager @mock_secretsmanager def test_get_secret_string(mocker): expected: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } mocker.patch("get_secret_string.get_secret_string", return_value=expected) secret_value: dict = get_secret_string() assert secret_value == expected
$ pytest 中略 E botocore.errorfactory.ResourceNotFoundException: An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secrets Manager can't find the specified secret. /Users/enokawa/.anyenv/envs/pyenv/versions/3.9.12/lib/python3.9/site-packages/botocore/client.py:708: ResourceNotFoundException =============================================================== short test summary info =============================================================== FAILED test_get_secret_string.py::test_get_secret_string - botocore.errorfactory.ResourceNotFoundException: An error occurred (ResourceNotFoundExcep... ================================================================== 1 failed in 0.33s ==================================================================
ResourceNotFoundException となった。事前に moto のなか(?)で Secrets を作成しておく必要がありそうだ。CreateSecret API を呼び出して、Secrets を作成する Fixture を用意する。
# test_get_secret_string.py import json import boto3 import pytest from get_secret_string import get_secret_string from moto import mock_secretsmanager @pytest.fixture(scope="function", autouse=False) def create_secret(): def _create_secret(secret_name: str, secret_value: str): client = boto3.client('secretsmanager', region_name="ap-northeast-1") client.create_secret(Name=secret_name,SecretString=secret_value) return _create_secret @mock_secretsmanager def test_get_secret_string(create_secret, mocker): expected: dict = { "username": "admin", "password": "FLsr(uZoI@@Z+Aa?", "engine": "mysql", "host": "0.0.0.0", "port": "3306", "dbname": "pytest" } create_secret(secret_name="sample/pytest", secret_value=json.dumps(expected)) mocker.patch("get_secret_string.get_secret_string", return_value=expected) secret_value: dict = get_secret_string() assert secret_value == expected
$ pytest ================================================================= test session starts ================================================================= platform darwin -- Python 3.9.12, pytest-7.1.3, pluggy-1.0.0 rootdir: /Users/enokawa/workspace/github.com/enokawa/pytest-sandbox/secrets_manager collected 1 item test_get_secret_string.py . [100%] ================================================================== 1 passed in 0.24s ==================================================================
テストが PASS することを確認できた。
おわりに
ステップバイステップで pytest と moto を利用して Secrets Manager をテストした。 恥ずかしながら、(pytest-)mock が内部で何をしているのかであったり、実関数を呼び出しているのかどうかであったり、moto が何をしているのかがちゃんと理解できていない。
このエントリを切り口として、理解できていない部分を実装して、理解を深めていきたい。
サンプルコードは以下。