pytest + motoでSecrets Managerをテストする

Secrets Manager に限らず、いつもやり方を忘れてしまうので備忘録として残しておく。

前提

  • macOS(Big Sur) 11.7.2
  • AWS CLI 2.5.6
  • Python 3.9.12
  • pytest 7.1.3
  • boto3 1.18.56
  • moto 3.1.16

事前準備

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 を想定している。

docs.aws.amazon.com

モジュール名は 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 メソッドを呼び出す。

boto3.amazonaws.com

必須の引数は 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 でモックできないか試してみる。

pypi.org

# 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 のモックはできそうということが分かった。

docs.getmoto.org

ではテストコードを実装していく。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 を用意する。

boto3.amazonaws.com

# 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 が何をしているのかがちゃんと理解できていない。

このエントリを切り口として、理解できていない部分を実装して、理解を深めていきたい。

サンプルコードは以下。

github.com