Turns out, setting up pytest and localstack is more difficult than expected, but got it working :)
I have two modes:
- Local mode: localstack is running in a terminal window using
localstack start
. This is useful for TDD, as you don't spin up new containers everytime. CICD mode: localstack is not running and it would be automatically started before all tests start.
Install packages:
pip install docker boto3 localstack_utils localstack-client
Setup pytest conftest:
conftest.py
import boto3
import docker
import pytest
import localstack_client.session
from localstack_utils.localstack import startup_localstack, stop_localstack
# patch boto3 to automatically use localstack.
@pytest.fixture(autouse=True)
def boto3_localstack_patch(monkeypatch):
session_ls = localstack_client.session.Session()
monkeypatch.setattr(boto3, "client", session_ls.client)
monkeypatch.setattr(boto3, "resource", session_ls.resource)
# check if localstack running locally using docker lib, if not running, use localstack lib to start it.
def is_localstack_running() -> bool:
try:
docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock')
container = docker_client.containers.get("localstack-main")
return container.attrs['State']['Status'] == "running"
except:
return False
@pytest.fixture(autouse=True, scope="session")
def setup_localstack():
if not is_localstack_running():
print("Setup localstack")
startup_localstack()
yield
if not is_localstack_running():
print("Teardown localstack")
stop_localstack()
Fixture to cleanup after each test:
@pytest.fixture(autouse=True)
def aws_fixture():
# print("Setup")
yield
# print("Cleanup")
# Cleanup S3
s3 = boto3.client("s3")
buckets = [item["Name"] for item in s3.list_buckets()["Buckets"]]
for bucket in buckets:
s3.delete_bucket(Bucket=bucket)
# Cleanup DynamoDB"
dynamodb = boto3.client("dynamodb")
tables_names = dynamodb.list_tables()["TableNames"]
for table_name in tables_names:
dynamodb.delete_table(TableName=table_name)
Then in the handler, add boto3 client. This funky way complies with AWS recommended way initialising global libraries and pytest can patch boto3 too.
handler.py
import functools
# DynamoDB Client
@functools.cache
def dynamodb_client() -> DynamoDBClient:
return boto3.client("dynamodb")
Thats it.
Full example:
handler_blog.py
from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSEvent
from aws_lambda_powertools.utilities.typing import LambdaContext
import functools
from mypy_boto3_dynamodb import DynamoDBClient
import boto3
# DynamoDB Client setup
@functools.cache
def dynamodb_client() -> DynamoDBClient:
return boto3.client("dynamodb")
def handler(event: SQSEvent, context: LambdaContext):
dynamodb_client().update_item(
TableName="super-table",
Key={"objectId": {"N": "123456"}},
ExpressionAttributeNames={"#name": "name"},
ExpressionAttributeValues={":name": {"S": "batman"}},
UpdateExpression="set #name = :name",
ReturnValues="NONE",
)
return []
handler_blog_test.py
import pytest
import boto3
from typing import Literal
from pydantic import BaseModel
from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSEvent
from .handler_blog import handler
@pytest.fixture(autouse=True)
def aws_fixture():
# print("Setup")
yield
# print("Cleanup")
# DynamoDB"
dynamodb = boto3.client("dynamodb")
tables_names = dynamodb.list_tables()["TableNames"]
for table_name in tables_names:
dynamodb.delete_table(TableName=table_name)
class CreateDynamoDB(BaseModel):
table_name: str
pk_name: str
pk_type: Literal["S", "N"]
@pytest.fixture
def create_dynamodb_table(create_dynamodb_table_config: CreateDynamoDB):
config = create_dynamodb_table_config
dynamodb = boto3.client("dynamodb")
dynamodb.create_table(
TableName=config.table_name,
KeySchema=[
{"AttributeName": config.pk_name, "KeyType": "HASH"},
],
AttributeDefinitions=[
{
"AttributeName": config.pk_name,
"AttributeType": config.pk_type,
}
],
BillingMode="PAY_PER_REQUEST",
)
class InsertDataSQSMessage(BaseModel):
id: int
name: str
def get_sqs_event_stub(body: InsertDataSQSMessage) -> SQSEvent:
return SQSEvent(
{
"Records": [
{
"messageId": "4f332f15-3930-4a00-8831-1706016678846",
"receiptHandle": "AQEB/123456789012+bjpfjcbH0fslWvMxNSXEJWn/VNCIi0TYmuZakYNQpQhhcl2EoPseeM4ctyfd/OQ5eiMqWhta+L+iZYIuHRQiIIjmMgJrfJsl6aVHI1vYQvTTwhxaBJh2582kvuAaRvQ0gbLzT/Pe+Zp+123456789012/2Luka8cdrsLlSHEHI+21N+tN5dOaxBoGCJk1wZti6UmcrEzz3T+123456789012/O+mbqSPvJEJnbGasJRUFcKIfocbokN4sMSl8eJJKN1QkWPqxinVmk1DkEYzyY+rzSTjE8IBgcGRrxc293eYDJdfzISXo8j97h83ITP4fm1vMDA2w0/cDvvL3m4ACmZjwoZWdfoBTvJwbB8bXEa86Ykew==",
"body": body.model_dump_json(),
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1706016678845",
"SenderId": "XXXXXXXXXXXXXXXXXXXXX",
"ApproximateFirstReceiveTimestamp": "1706016678846",
},
"messageAttributes": {},
"md5OfBody": "28f07e09c08aba530422dd193f991111",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:eu-central-1:123456789012:data",
"awsRegion": "eu-central-1",
}
]
}
)
class TestHandlerBlog:
def setup_method(self, method):
pass
def teardown_method(self, method):
pass
create_dynamodb_table_config = CreateDynamoDB(table_name="super-table", pk_name="objectId", pk_type="N")
@pytest.mark.parametrize("create_dynamodb_table_config", [create_dynamodb_table_config])
def test_insert_some_data_to_dynamodb(self, create_dynamodb_table):
# Prepare
# Act
event = get_sqs_event_stub(InsertDataSQSMessage(id=18393972737, name="company"))
result = handler(event=event, context={})
# Assert function
assert result == []
# Assert Table content
response = boto3.client("dynamodb").scan(TableName="super-table")
table_records = response["Items"]
assert len(table_records) == 1
assert table_records == [
{
"objectId": {"N": "123456"},
"name": {"S": "batman"},
}
]
ps. I also mapped vs code key "F1" to "testing.runAtCursor" and "F2" to "testing.runAll".
Top comments (0)