AWS Emulatorでテストのたびに空の状態から始める方法

​​AWS Emulator​Amazon S3,Boto3,DynamoDB,pytest,テスト

AWS Emulator は、作ったテーブルや S3 のファイルをメモリーにだけ置いています。止めればすべて消えるので、起動し直すことが、そのまま「テストデータの後片付け」になります。ローカルや CI の結合テストで、前の実行で作ったテーブルが残っていてテストが落ちる、ということを防げます。止める前と後で同じ一覧の操作を実行した結果と、テストのたびに起動し直すスクリプト、pytest の fixture を、検証環境で実行したまま載せます。

補足

検証環境は Ubuntu 24.04(x86_64)、AWS Emulator 1.0.353(Debian パッケージ)、AWS CLI 2.40.3、Python 3.11、Boto3 1.44.3、pytest 9.2.0 です。AWS のアカウントは使わず、リクエストはすべて手元のエミュレーターに送りました。本物の AWS での動きと料金は確かめておらず、記事で触れるところは公式ドキュメントを出どころにしています。Docker で動かす場合と、Python 以外の SDK は試していません。

テーブルと S3 のファイルを作っておく

入れ方は公式ガイドの Linux 向けの手順のとおりです。テレメトリーを切る -t off を付けて起動し、別のシェルで公式ドキュメントにある環境変数を設定して、AWS CLI の接続先をエミュレーターに向けました。検証では、1つのスクリプトの中から、バックグラウンドで起動しています。

bash
aws-emulator -t off
bash
export AWS_ENDPOINT_URL=https://127.0.0.1:9297
export AWS_SHARED_CREDENTIALS_FILE=~/.aws/emulator/9297/emulator_credentials
export AWS_CONFIG_FILE=~/.aws/emulator/9297/config
export AWS_CA_BUNDLE=~/.aws/emulator/9297/ca.pem
export AWS_EC2_METADATA_DISABLED=true
export AWS_DISABLE_HOST_PREFIX_INJECTION=true
export AWS_REGION=us-east-1
export AWS_DEFAULT_REGION=us-east-1
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE

DynamoDB のテーブルを作り、項目を1つ書き込みます。作った直後のテーブルは CREATING なので、aws dynamodb wait table-exists で ACTIVE になるのを待ってから書き込みます。

text
$ aws dynamodb create-table --table-name example-table --attribute-definitions AttributeName=id,AttributeType=S --key-schema AttributeName=id,KeyType=HASH --billing-mode PAY_PER_REQUEST --query 'TableDescription.[TableName,TableStatus]' --output text
example-table	CREATING
$ aws dynamodb wait table-exists --table-name example-table
$ aws dynamodb describe-table --table-name example-table --query 'Table.TableStatus' --output text
ACTIVE
$ aws dynamodb put-item --table-name example-table --item '{"id":{"S":"neko-1"},"name":{"S":"tama"}}'

S3 にはバケットを作り、5バイトのテキストファイルを置きました。

text
$ aws s3api create-bucket --bucket example-bucket
{
    "Location": "/example-bucket",
    "BucketArn": "arn:aws:s3:::example-bucket"
}
$ aws s3 cp hello.txt s3://example-bucket/hello.txt
upload: ./hello.txt to s3://example-bucket/hello.txt

aws s3 cp の出力は、進み具合の表示を省いています。

止める前と後で、同じ一覧を出して比べる

止める前に、テーブルの一覧、バケットの一覧、バケットの中のファイル、テーブルの中身を出しました。作ったものがすべて見えています。

text
$ aws dynamodb list-tables
{
    "TableNames": [
        "example-table"
    ]
}
$ aws s3api list-buckets --query 'Buckets[].Name'
[
    "example-bucket"
]
$ aws s3 ls s3://example-bucket
2026-11-14 03:52:32          5 hello.txt
$ aws dynamodb scan --table-name example-table --query 'Items'
[
    {
        "id": {
            "S": "neko-1"
        },
        "name": {
            "S": "tama"
        }
    }
]

エミュレーターに SIGTERM を送って止めます(起動した端末で Ctrl+C を押すのと同じく、きれいに止まる止め方です)。終了コードは 0 で、ポート番号の名前が付いたフォルダー ~/.aws/emulator/9297/ の中は空になりました。検証ではスクリプトの中で kill -TERM $PID を実行したので、下ではプロセスIDの部分を書き換えています。

text
$ kill -TERM <エミュレーターのプロセスID>
emulator exit code: 0
$ ls -A ~/.aws/emulator/9297/

もう一度 aws-emulator -t off で起動し、環境変数はそのままで、止める前と同じ4つのコマンドを実行しました。

text
$ aws dynamodb list-tables
{
    "TableNames": []
}
$ aws s3api list-buckets --query 'Buckets[].Name'
[]
$ aws s3 ls s3://example-bucket

aws: [ERROR]: An error occurred (NoSuchBucket) when calling the ListObjectsV2 operation: The specified bucket does not exist
$ aws dynamodb scan --table-name example-table --query 'Items'

aws: [ERROR]: An error occurred (ResourceNotFoundException) when calling the Scan operation: Requested resource not found

テーブルもバケットも、中のファイルや項目も残っていません。同じ名前の example-table を作り直すと、エラーにならずに作れました。

起動、テスト、止める、次の起動の4段の流れ。止めると作ったテーブルやS3のファイルがすべて消え、次の起動は空の状態から始まる。SIGKILLで落ちたときは鍵と証明書のファイルだけが残る
テストの実行ごとに、空の状態から始める流れ

落ちたときも、作ったものは残らない

公式のコマンドリファレンスでは、Ctrl+C、SIGTERM、--idle-timeout で止まったときを「きれいに止まった」としていて、そのときはそのフォルダーのファイルを消すと書かれています。クラッシュしたときはファイルが残り、次に同じポートで起動したエミュレーターが置き換えるとあります。

クラッシュの代わりに SIGKILL で止めて確かめました。終了コードは 137 で、鍵と証明書のファイルは4つとも残っていました(こちらもプロセスIDの部分を書き換えています)。

text
$ kill -KILL <エミュレーターのプロセスID>
emulator exit code: 137
$ ls -A ~/.aws/emulator/9297/
ca.pem
config
emulator_credentials
truststore.p12

そのまま起動し直すと、ファイルは新しいものに置き換わりました。emulator_credentials の SHA-256 が、落ちる前と変わっています。SIGKILL の前に作り直した example-table も残っていませんでした。

text
$ sha256sum ~/.aws/emulator/9297/emulator_credentials   # 落ちる前
8c066c5a070b752df4e7e988eddb71d46fa9710d1ee02b4116778a7bfcd97b9a  ~/.aws/emulator/9297/emulator_credentials
$ sha256sum ~/.aws/emulator/9297/emulator_credentials   # 起動し直した後
d2b1e731a4961a68d3200370f4ed8b6fceb5d87a564d748a47a7db004bb8bf73  ~/.aws/emulator/9297/emulator_credentials
$ aws dynamodb list-tables
{
    "TableNames": []
}
$ aws s3api list-buckets --query 'Buckets[].Name'
[]

リファレンスには、エミュレーターに状態をリセットする管理用の API はなく、すべての状態をメモリーに置いているので、空の状態から始めたいときは起動し直すようにと書かれています。aws-emulator --help に出る管理用の API も、ヘルスチェックの GET /__admin/health だけでした。

同じエミュレーターのままだと、2回目のテストが落ちる

テストでの使い方を見る前に、起動し直さないと何が起きるかを確かめます。次の pytest のテストは、DynamoDB に orders テーブルを作って1件書き込み、S3 に receipts バケットを作ってファイルを1つ置きます。

python
import boto3


def test_save_order():
    ddb = boto3.client("dynamodb")
    ddb.create_table(
        TableName="orders",
        AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
        KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
        BillingMode="PAY_PER_REQUEST",
    )
    ddb.get_waiter("table_exists").wait(TableName="orders")
    ddb.put_item(TableName="orders", Item={"id": {"S": "order-1"}})
    assert ddb.scan(TableName="orders")["Count"] == 1


def test_upload_receipt():
    s3 = boto3.client("s3")
    s3.create_bucket(Bucket="receipts")
    s3.put_object(Bucket="receipts", Key="order-1.txt", Body=b"ok")
    keys = [o["Key"] for o in s3.list_objects_v2(Bucket="receipts")["Contents"]]
    assert keys == ["order-1.txt"]

エミュレーターを1つ起動したまま、2回続けて実行しました。検証では、この記事のどの pytest の実行にも、キャッシュのファイルを作らない -p no:cacheprovider を付けています。1回目は2つとも通り、2回目は test_save_order が Table already exists: orders で落ちました(2回目の出力は、エラーの行と結果の行だけを抜き出しています)。

text
$ python -m pytest -q   # 1回目
..                                                                       [100%]
2 passed in 3.25s
$ python -m pytest -q   # 2回目
E           botocore.errorfactory.ResourceInUseException: An error occurred (ResourceInUseException) when calling the CreateTable operation: Table already exists: orders
FAILED test_orders.py::test_save_order - botocore.errorfactory.ResourceInUseE...
1 failed, 1 passed in 0.24s

S3 のほうの test_upload_receipt は、2回目も通りました。前の実行のバケットが残っていても create_bucket はエラーにならず、同じキーでファイルを置いたので、一覧も1件のままだったからです。前の実行の残りものが、いつもエラーで表に出るとは限りません。

テストの実行ごとに起動し直すスクリプト

テストを走らせるたびにエミュレーターを起動し、終わったら止めるシェルスクリプトです。公式ドキュメントの制限の節によると、AWS は長い時間動かし続ける使い方をテストしていません。テストのために起動して終わったら止めるか、--idle-timeout で自分で止まるようにすること、起動し直せばテストが空の状態から始まることも書かれています。

bash
#!/bin/bash
# テストを走らせるたびに、新しいエミュレーターを起動して、終わったら止める
set -euo pipefail

LOG=$(mktemp)
aws-emulator -t off -p 0 --idle-timeout 600 > "$LOG" 2>/dev/null &
EMU_PID=$!
trap 'kill -TERM "$EMU_PID" 2>/dev/null; wait "$EMU_PID" 2>/dev/null || true; rm -f "$LOG"' EXIT

# 起動の行(message が emulator server information)が出るまで待って、接続先を読む
for i in $(seq 30); do
  INFO=$(grep '"emulator server information"' "$LOG" || true)
  [ -n "$INFO" ] && break
  sleep 1
done
[ -n "$INFO" ] || { echo "emulator did not start" >&2; exit 1; }
ENDPOINT=$(echo "$INFO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["fields"]["endpoint_url"])')
DIR=$(dirname "$(echo "$INFO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["fields"]["config_file"])')")

export AWS_ENDPOINT_URL="$ENDPOINT"
export AWS_SHARED_CREDENTIALS_FILE="$DIR/emulator_credentials"
export AWS_CONFIG_FILE="$DIR/config"
export AWS_CA_BUNDLE="$DIR/ca.pem"
export AWS_EC2_METADATA_DISABLED=true
export AWS_DISABLE_HOST_PREFIX_INJECTION=true
export AWS_REGION=us-east-1
export AWS_DEFAULT_REGION=us-east-1
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE

curl -sf --cacert "$AWS_CA_BUNDLE" "$ENDPOINT/__admin/health" > /dev/null
echo "emulator: $ENDPOINT"
python -m pytest -q "$@"

工夫したのは次の3点です。

  • -p 0 で、空いているポートを OS に選ばせます。前の実行のエミュレーターが何かの理由で残っていても、ポートがぶつかりません。選ばれたポートは、リファレンスのとおり、ログの emulator server information の行にある endpoint_url から読みます。リファレンスによると、この行とその項目は頼ってよいとされていて、ほかの行はリリースごとに変わることがあります。
  • trap ... EXIT で、テストが落ちても、スクリプトが途中で止まっても、最後に SIGTERM を送って止めます。
  • --idle-timeout 600 は、trap が働かずにエミュレーターが残ったときの備えです。API の呼び出しが600秒ないと、エミュレーターが自分で止まります。

環境変数をスクリプトの中で設定しているのは、公式ドキュメントの注意に合わせたものです。

注意

公式ドキュメントでは、テストを動かすシェルに AWS_ENDPOINT_URL が設定されていないと、コードは見つかった認証情報で本物の AWS を呼ぶと注意しています。環境変数はテストを動かすのと同じスクリプトの中で設定し、テストのジョブの環境に本物の認証情報を置かないようにと書かれています。

このスクリプトで2回続けて実行すると、2回とも2つのテストが通りました。ポートは毎回違うものが選ばれています。スクリプト全体にかかった時間は、2回とも4.6秒ほどでした。

text
$ ./run-tests.sh   # 1回目
emulator: https://127.0.0.1:33901
..                                                                       [100%]
2 passed in 3.20s
$ ./run-tests.sh   # 2回目
emulator: https://127.0.0.1:42575
..                                                                       [100%]
2 passed in 3.20s

終わったあとは、aws-emulator のプロセスは1つも残っていませんでした。~/.aws/emulator/ にはポートごとのフォルダーが残りますが、中のファイルは0個でした。

テスト関数ごとに起動する pytest の fixture

1つのテスト関数が作ったものを次のテスト関数に持ち越したくないときは、テスト関数ごとに起動し直します。検証環境で起動からヘルスチェックが ok を返すまでの時間を5回測ると、0.23〜0.34秒でした。次の conftest.py は、テスト関数ごとにエミュレーターを起動して、環境変数を設定し、終わったら SIGTERM で止める fixture です。

python
import json
import os
import subprocess

import boto3
import pytest


@pytest.fixture(autouse=True)
def fresh_emulator(monkeypatch):
    """テスト関数ごとに、空のエミュレーターを起動して、終わったら止める"""
    proc = subprocess.Popen(
        ["aws-emulator", "-t", "off", "-p", "0", "--idle-timeout", "600"],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
    )
    # 起動の行(message が emulator server information)が出るまで読む
    info = None
    for line in proc.stdout:
        fields = json.loads(line).get("fields", {})
        if fields.get("message") == "emulator server information":
            info = fields
            break
    if info is None:
        proc.kill()
        pytest.fail("emulator did not start")

    folder = os.path.dirname(info["config_file"])
    monkeypatch.setenv("AWS_ENDPOINT_URL", info["endpoint_url"])
    monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", os.path.join(folder, "emulator_credentials"))
    monkeypatch.setenv("AWS_CONFIG_FILE", info["config_file"])
    monkeypatch.setenv("AWS_CA_BUNDLE", info["ca_file"])
    monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
    monkeypatch.setenv("AWS_DISABLE_HOST_PREFIX_INJECTION", "true")
    monkeypatch.setenv("AWS_REGION", "us-east-1")
    monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
    for name in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_PROFILE"]:
        monkeypatch.delenv(name, raising=False)
    # boto3.client() が使う既定のセッションは、最初に読んだ鍵を覚えている。
    # 前のテストの鍵が残らないように、テストごとに作り直させる
    monkeypatch.setattr(boto3, "DEFAULT_SESSION", None)

    yield info["endpoint_url"]

    proc.terminate()  # SIGTERM で止めると、メモリーの中身とポートのフォルダーのファイルが消える
    proc.wait(timeout=30)

テストには、さきほどの2つに加えて、始まったときにテーブルもバケットもないことを確かめる test_starts_empty を足しました。

python
def test_starts_empty():
    assert boto3.client("dynamodb").list_tables()["TableNames"] == []
    assert boto3.client("s3").list_buckets()["Buckets"] == []

2回続けて実行すると、どちらも3つとも通りました。1回目の出力です(見出しの行と、--durations=0 の時間の表は省いています)。

text
$ python -m pytest -v --durations=0
collecting ... collected 3 items

test_orders.py::test_save_order PASSED                                   [ 33%]
test_orders.py::test_upload_receipt PASSED                               [ 66%]
test_orders.py::test_starts_empty PASSED                                 [100%]
============================== 3 passed in 4.04s ===============================

--durations=0 で見ると、fixture の準備(setup)は1回あたり0.16〜0.34秒、片付け(teardown)は0.02〜0.03秒でした。

Boto3 の既定のセッションが、前のエミュレーターの鍵を覚えている

上の fixture で、最後の monkeypatch.setattr(boto3, "DEFAULT_SESSION", None) を書かずに実行すると、最初のテストだけが通り、残りの2つが落ちました(エラーの行と結果の行だけを抜き出しています)。

text
$ python -m pytest -q
E           botocore.errorfactory.AccessDenied: An error occurred (AccessDenied) when calling the CreateBucket operation: Sign the request with the emulator credentials
E           botocore.exceptions.ClientError: An error occurred (UnrecognizedClientException) when calling the ListTables operation: The security token included in the request is invalid.
FAILED test_orders.py::test_upload_receipt - botocore.errorfactory.AccessDeni...
FAILED test_orders.py::test_starts_empty - botocore.exceptions.ClientError: A...
2 failed, 1 passed in 3.95s

boto3.client() は、Boto3 のセッションの説明にあるとおり、自動で作られる既定のセッションを使います。エミュレーターは起動のたびに新しい鍵を作るので、2つ目のエミュレーターでは、前の鍵で署名されたリクエストが断られました。確かめるために、エミュレーターを2回起動し、そのたびに鍵のファイルの中身と、既定のセッションが持っている鍵を比べました。2回目は一致しませんでした。

text
$ python check_cache.py
run 1: key in file == key in DEFAULT_SESSION: True
run 2: key in file == key in DEFAULT_SESSION: False

boto3.DEFAULT_SESSION を None に戻すと、次の boto3.client() で既定のセッションが作り直され、新しい鍵のファイルが読まれます。シェルスクリプトで実行ごとに起動し直す方法では、pytest のプロセスも毎回新しく起動するので、この問題は起きませんでした。

本物の AWS とは違うところ

ここからは公式ドキュメントに書かれていることです。AWS Emulator を使うのに AWS のアカウントは要らず、追加の料金もかからないと書かれています。料金がかかるのは、AWS にデプロイして作った本物のリソースだけです。エミュレーターの中のリソースはメモリーにあり、どれだけ置けるかは動かしているマシンしだいで、止めるとすべて消えると説明されています。

エミュレーターは開発とテストのためのもので、本番のワークロードには使わないようにとも書かれています。IAM のポリシーは AWS と同じようには判定しないので、権限の確認には使えません。どこまで AWS と同じに動くかはサービスによって違い、GitHub のリリースページに、リリースのたびにサービスごとの結果が載ります。

​Posted by 86