Testing DynamoDB Locally: DynamoDB Local, Localstack, and AWS SDK Mocking
DynamoDB is fast and serverless in production — but testing against live AWS DynamoDB is expensive, slow, and requires real AWS credentials. DynamoDB Local, LocalStack, and AWS SDK mocking solve this, letting you run fast, reliable tests without touching AWS. This guide covers all three approaches across Python and Node.js.
Three Approaches to DynamoDB Testing
| Approach | Fidelity | Speed | Cost | Best For |
|---|---|---|---|---|
| AWS SDK mocks | Low | Fastest | Free | Unit tests, pure logic |
| DynamoDB Local (JAR) | High | Fast | Free | Most tests |
| LocalStack | Very High | Moderate | Free/Pro | Multi-service AWS tests |
| Real DynamoDB | Perfect | Slow | $$ | Final integration tests |
DynamoDB Local: The Official Emulator
DynamoDB Local is an official AWS JAR file that runs a complete DynamoDB-compatible server locally. It supports virtually all DynamoDB features.
Option 1: Docker Image
docker run -p 8000:8000 amazon/dynamodb-local:latest \
-jar DynamoDBLocal.jar -sharedDb -inMemoryFor tests, add -inMemory to avoid disk writes and -sharedDb to use a single shared database file (or omit for per-connection isolation).
Option 2: Testcontainers
# Python
from testcontainers.core.container import DockerContainer
import boto3
class DynamoDBLocalContainer(DockerContainer):
def __init__(self):
super().__init__("amazon/dynamodb-local:latest")
self.with_command("-jar DynamoDBLocal.jar -inMemory -sharedDb")
self.with_exposed_ports(8000)
def get_endpoint_url(self):
return f"http://localhost:{self.get_exposed_port(8000)}"// Node.js
const { GenericContainer } = require('testcontainers');
const container = await new GenericContainer('amazon/dynamodb-local:latest')
.withCommand(['-jar', 'DynamoDBLocal.jar', '-inMemory', '-sharedDb'])
.withExposedPorts(8000)
.start();
const endpoint = `http://localhost:${container.getMappedPort(8000)}`;Python Testing with boto3
Setup and Fixtures
import pytest
import boto3
from testcontainers.core.container import DockerContainer
@pytest.fixture(scope="session")
def dynamodb_container():
container = DockerContainer("amazon/dynamodb-local:latest")
container.with_command("-jar DynamoDBLocal.jar -inMemory -sharedDb")
container.with_exposed_ports(8000)
container.start()
yield container
container.stop()
@pytest.fixture(scope="session")
def dynamodb_resource(dynamodb_container):
port = dynamodb_container.get_exposed_port(8000)
return boto3.resource(
'dynamodb',
endpoint_url=f'http://localhost:{port}',
region_name='us-east-1',
aws_access_key_id='fake',
aws_secret_access_key='fake'
)
@pytest.fixture(scope="session")
def dynamodb_client(dynamodb_container):
port = dynamodb_container.get_exposed_port(8000)
return boto3.client(
'dynamodb',
endpoint_url=f'http://localhost:{port}',
region_name='us-east-1',
aws_access_key_id='fake',
aws_secret_access_key='fake'
)
@pytest.fixture(scope="function")
def users_table(dynamodb_resource):
table = dynamodb_resource.create_table(
TableName='Users',
KeySchema=[
{'AttributeName': 'pk', 'KeyType': 'HASH'},
{'AttributeName': 'sk', 'KeyType': 'RANGE'}
],
AttributeDefinitions=[
{'AttributeName': 'pk', 'AttributeType': 'S'},
{'AttributeName': 'sk', 'AttributeType': 'S'},
],
BillingMode='PAY_PER_REQUEST'
)
table.wait_until_exists()
yield table
table.delete()Testing CRUD Operations
def test_put_and_get_item(users_table):
users_table.put_item(Item={
'pk': 'USER#alice',
'sk': 'PROFILE',
'name': 'Alice',
'email': 'alice@example.com',
'age': 30
})
response = users_table.get_item(Key={
'pk': 'USER#alice',
'sk': 'PROFILE'
})
item = response.get('Item')
assert item is not None
assert item['name'] == 'Alice'
assert item['email'] == 'alice@example.com'
def test_conditional_write(users_table):
"""Test optimistic locking with condition expressions"""
users_table.put_item(Item={
'pk': 'USER#bob',
'sk': 'PROFILE',
'name': 'Bob',
'version': 1
})
# Update succeeds with correct version
users_table.update_item(
Key={'pk': 'USER#bob', 'sk': 'PROFILE'},
UpdateExpression='SET #name = :name, version = :new_version',
ConditionExpression='version = :expected_version',
ExpressionAttributeNames={'#name': 'name'},
ExpressionAttributeValues={
':name': 'Robert',
':new_version': 2,
':expected_version': 1
}
)
# Verify update
item = users_table.get_item(
Key={'pk': 'USER#bob', 'sk': 'PROFILE'}
)['Item']
assert item['name'] == 'Robert'
assert item['version'] == 2
def test_conditional_write_conflict(users_table):
"""Conflicting update is rejected"""
from botocore.exceptions import ClientError
users_table.put_item(Item={
'pk': 'USER#carol', 'sk': 'PROFILE', 'version': 1
})
with pytest.raises(ClientError) as exc:
users_table.update_item(
Key={'pk': 'USER#carol', 'sk': 'PROFILE'},
UpdateExpression='SET version = :v',
ConditionExpression='version = :expected',
ExpressionAttributeValues={':v': 2, ':expected': 99} # Wrong version
)
assert exc.value.response['Error']['Code'] == 'ConditionalCheckFailedException'
def test_query_with_sort_key(users_table):
"""Test Query operation with sort key conditions"""
user_id = 'USER#dana'
users_table.put_item(Item={'pk': user_id, 'sk': 'ORDER#2024-01-01', 'total': 99})
users_table.put_item(Item={'pk': user_id, 'sk': 'ORDER#2024-02-01', 'total': 149})
users_table.put_item(Item={'pk': user_id, 'sk': 'ORDER#2024-03-01', 'total': 49})
users_table.put_item(Item={'pk': user_id, 'sk': 'PROFILE', 'name': 'Dana'})
from boto3.dynamodb.conditions import Key
response = users_table.query(
KeyConditionExpression=Key('pk').eq(user_id) & Key('sk').begins_with('ORDER#')
)
items = response['Items']
assert len(items) == 3
assert all(item['sk'].startswith('ORDER#') for item in items)Testing with moto (AWS Mock Library)
For pure unit tests without Docker, moto mocks AWS services in-process:
import boto3
import pytest
from moto import mock_aws
@pytest.fixture
def aws_credentials(monkeypatch):
monkeypatch.setenv('AWS_ACCESS_KEY_ID', 'fake')
monkeypatch.setenv('AWS_SECRET_ACCESS_KEY', 'fake')
monkeypatch.setenv('AWS_DEFAULT_REGION', 'us-east-1')
@mock_aws
def test_user_service_creates_item(aws_credentials):
# Create table in mock
client = boto3.client('dynamodb', region_name='us-east-1')
client.create_table(
TableName='Users',
KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}],
AttributeDefinitions=[{'AttributeName': 'pk', 'AttributeType': 'S'}],
BillingMode='PAY_PER_REQUEST'
)
# Import your service (it uses boto3 internally)
from myapp.user_service import UserService
service = UserService(table_name='Users', region='us-east-1')
user_id = service.create_user(email='test@example.com', name='Test')
user = service.get_user(user_id)
assert user['email'] == 'test@example.com'Node.js Testing with aws-sdk
DynamoDB Client Configuration for Tests
// config/dynamodb.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb');
function createClient(options = {}) {
const clientConfig = {
region: process.env.AWS_REGION || 'us-east-1',
...options
};
if (process.env.DYNAMODB_ENDPOINT) {
clientConfig.endpoint = process.env.DYNAMODB_ENDPOINT;
clientConfig.credentials = {
accessKeyId: 'fake',
secretAccessKey: 'fake'
};
}
const client = new DynamoDBClient(clientConfig);
return DynamoDBDocumentClient.from(client);
}
module.exports = { createClient };Jest Test Setup
// jest.setup.js
const { GenericContainer } = require('testcontainers');
let container;
beforeAll(async () => {
container = await new GenericContainer('amazon/dynamodb-local:latest')
.withCommand(['-jar', 'DynamoDBLocal.jar', '-inMemory', '-sharedDb'])
.withExposedPorts(8000)
.start();
process.env.DYNAMODB_ENDPOINT = `http://localhost:${container.getMappedPort(8000)}`;
process.env.AWS_REGION = 'us-east-1';
}, 30000);
afterAll(async () => {
if (container) await container.stop();
});Table Setup and Tests
const { DynamoDBClient, CreateTableCommand, DeleteTableCommand } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand, UpdateCommand } = require('@aws-sdk/lib-dynamodb');
describe('ProductRepository', () => {
let docClient;
const TABLE_NAME = 'Products';
beforeAll(async () => {
const client = new DynamoDBClient({
endpoint: process.env.DYNAMODB_ENDPOINT,
region: 'us-east-1',
credentials: { accessKeyId: 'fake', secretAccessKey: 'fake' }
});
docClient = DynamoDBDocumentClient.from(client);
await client.send(new CreateTableCommand({
TableName: TABLE_NAME,
KeySchema: [
{ AttributeName: 'pk', KeyType: 'HASH' },
{ AttributeName: 'sk', KeyType: 'RANGE' }
],
AttributeDefinitions: [
{ AttributeName: 'pk', AttributeType: 'S' },
{ AttributeName: 'sk', AttributeType: 'S' },
],
BillingMode: 'PAY_PER_REQUEST'
}));
});
afterAll(async () => {
const client = new DynamoDBClient({ endpoint: process.env.DYNAMODB_ENDPOINT });
await client.send(new DeleteTableCommand({ TableName: TABLE_NAME }));
});
test('stores and retrieves a product', async () => {
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: 'PRODUCT#widget-001',
sk: 'METADATA',
name: 'Widget',
price: 9.99,
category: 'hardware'
}
}));
const result = await docClient.send(new GetCommand({
TableName: TABLE_NAME,
Key: { pk: 'PRODUCT#widget-001', sk: 'METADATA' }
}));
expect(result.Item.name).toBe('Widget');
expect(result.Item.price).toBe(9.99);
});
test('handles missing items gracefully', async () => {
const result = await docClient.send(new GetCommand({
TableName: TABLE_NAME,
Key: { pk: 'PRODUCT#nonexistent', sk: 'METADATA' }
}));
expect(result.Item).toBeUndefined();
});
test('atomic counter increment', async () => {
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: { pk: 'COUNTER#pageviews', sk: 'GLOBAL', count: 0 }
}));
// Increment atomically
const result = await docClient.send(new UpdateCommand({
TableName: TABLE_NAME,
Key: { pk: 'COUNTER#pageviews', sk: 'GLOBAL' },
UpdateExpression: 'SET #count = #count + :incr',
ExpressionAttributeNames: { '#count': 'count' },
ExpressionAttributeValues: { ':incr': 1 },
ReturnValues: 'UPDATED_NEW'
}));
expect(result.Attributes.count).toBe(1);
});
});Mocking aws-sdk with Jest
For pure unit tests with no container:
// __mocks__/@aws-sdk/client-dynamodb.js
const mockSend = jest.fn();
const DynamoDBClient = jest.fn().mockImplementation(() => ({
send: mockSend
}));
module.exports = { DynamoDBClient, mockSend };
// In test files
describe('UserService unit tests', () => {
const { mockSend } = require('@aws-sdk/client-dynamodb');
beforeEach(() => {
mockSend.mockReset();
});
test('get user returns null when not found', async () => {
mockSend.mockResolvedValueOnce({ Item: undefined });
const service = new UserService('Users');
const result = await service.getUser('nonexistent');
expect(result).toBeNull();
expect(mockSend).toHaveBeenCalledTimes(1);
});
test('handles DynamoDB throttling with retry', async () => {
const { ProvisionedThroughputExceededException } = require('@aws-sdk/client-dynamodb');
mockSend
.mockRejectedValueOnce(new ProvisionedThroughputExceededException({ $metadata: {} }))
.mockResolvedValueOnce({ Item: { pk: 'USER#123', name: 'Alice' } });
const user = await service.getUserWithRetry('123');
expect(user.name).toBe('Alice');
expect(mockSend).toHaveBeenCalledTimes(2);
});
});CI/CD Integration
GitHub Actions
name: DynamoDB Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start DynamoDB Local
run: |
docker run -d -p 8000:8000 amazon/dynamodb-local:latest \
-jar DynamoDBLocal.jar -inMemory -sharedDb
- name: Wait for DynamoDB Local
run: |
for i in {1..30}; do
if curl -s http://localhost:8000 > /dev/null; then break; fi
sleep 1
done
- name: Run tests
run: npm test
env:
DYNAMODB_ENDPOINT: http://localhost:8000
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: fake
AWS_SECRET_ACCESS_KEY: fakeMonitoring DynamoDB in Production
Local testing covers logic, but DynamoDB production failures are often capacity-related:
ProvisionedThroughputExceededExceptionunder traffic spikes- Hot partition keys causing uneven load
- GSI throttling separate from main table
- Backup/restore operations consuming capacity
HelpMeTest monitors your live API endpoints that depend on DynamoDB, running continuous end-to-end tests every 5 minutes. Get alerted immediately when DynamoDB throttling causes your application to degrade — before users notice. Write tests in plain English, no infrastructure required.
Summary
Start with moto (Python) or Jest SDK mocks for pure unit tests — zero infrastructure, maximum speed. Use DynamoDB Local via Testcontainers for integration tests that need full DynamoDB fidelity. Reserve real DynamoDB for final pre-production smoke tests. Monitor production continuously to catch the capacity and operational issues that local tests can't simulate.