Testing
Testing DynamoDB code usually means moto, localstack, or Docker. pydynox has a simpler option: an in-memory backend. No setup, no extra dependencies, just fast tests.
Why use this?
- Zero dependencies - Already included in pydynox. No extra packages to install.
- No cold start impact - Only loads when you import it. Your Lambda stays fast.
- Auto-registered fixtures - pytest finds them automatically. No conftest.py needed.
- Fast - In-memory storage. No network, no Docker.
Key features
- In-memory backend that mimics DynamoDB
- Auto-registered pytest fixtures
- Seed data support
- Test isolation (each test starts fresh)
- Works with all pydynox operations
Getting started
Basic usage
Just add pydynox_memory_backend to your test function. That's it.
"""Basic usage of pydynox_memory_backend fixture (async - default)."""
import pytest
from pydynox import Model, ModelConfig
from pydynox.attributes import NumberAttribute, StringAttribute
class User(Model):
model_config = ModelConfig(table="users")
pk = StringAttribute(partition_key=True)
name = StringAttribute()
age = NumberAttribute(default=0)
@pytest.mark.asyncio
async def test_create_user(pydynox_memory_backend):
"""Test creating a user - no DynamoDB needed!"""
user = User(pk="USER#1", name="John", age=30)
await user.save()
found = await User.get(pk="USER#1")
assert found is not None
assert found.name == "John"
assert found.age == 30
@pytest.mark.asyncio
async def test_update_user(pydynox_memory_backend):
"""Test updating a user."""
user = User(pk="USER#1", name="Jane")
await user.save()
await user.update(name="Janet", age=25)
found = await User.get(pk="USER#1")
assert found.name == "Janet"
assert found.age == 25
@pytest.mark.asyncio
async def test_delete_user(pydynox_memory_backend):
"""Test deleting a user."""
user = User(pk="USER#1", name="Bob")
await user.save()
await user.delete()
assert await User.get(pk="USER#1") is None
"""Basic usage of pydynox_memory_backend fixture (sync)."""
from pydynox import Model, ModelConfig
from pydynox.attributes import NumberAttribute, StringAttribute
class User(Model):
model_config = ModelConfig(table="users")
pk = StringAttribute(partition_key=True)
name = StringAttribute()
age = NumberAttribute(default=0)
def test_create_user(pydynox_memory_backend):
"""Test creating a user - no DynamoDB needed!"""
user = User(pk="USER#1", name="John", age=30)
user.sync_save()
found = User.sync_get(pk="USER#1")
assert found is not None
assert found.name == "John"
assert found.age == 30
def test_update_user(pydynox_memory_backend):
"""Test updating a user."""
user = User(pk="USER#1", name="Jane")
user.sync_save()
user.sync_update(name="Janet", age=25)
found = User.sync_get(pk="USER#1")
assert found.name == "Janet"
assert found.age == 25
def test_delete_user(pydynox_memory_backend):
"""Test deleting a user."""
user = User(pk="USER#1", name="Bob")
user.sync_save()
user.sync_delete()
assert User.sync_get(pk="USER#1") is None
The fixture is auto-discovered by pytest. No imports needed, no conftest.py setup.
How it works
When you use pydynox_memory_backend:
- pydynox switches to an in-memory storage
- All
save(),get(),query(), etc. use this storage - After the test, storage is cleared
- The original client is restored
Each test is isolated. Data from one test doesn't leak to another.
Fixtures
pydynox provides three fixtures:
| Fixture | Use case |
|---|---|
pydynox_memory_backend |
Most tests - empty database per test |
pydynox_memory_backend_factory |
Tests that need custom seed data |
pydynox_memory_backend_seeded |
Tests that share common seed data |
pydynox_memory_backend
The simplest option. Each test starts with empty tables.
import pytest
@pytest.mark.asyncio
async def test_create_user(pydynox_memory_backend):
user = User(pk="USER#1", name="John")
await user.save()
assert await User.get(pk="USER#1") is not None
pydynox_memory_backend_factory
Use when you need pre-populated data:
"""Using seed data with pydynox_memory_backend_factory (async - default)."""
import pytest
from pydynox import Model, ModelConfig
from pydynox.attributes import NumberAttribute, StringAttribute
class User(Model):
model_config = ModelConfig(table="users")
pk = StringAttribute(partition_key=True)
name = StringAttribute()
age = NumberAttribute(default=0)
class Order(Model):
model_config = ModelConfig(table="orders")
pk = StringAttribute(partition_key=True)
sk = StringAttribute(sort_key=True)
total = NumberAttribute()
@pytest.mark.asyncio
async def test_with_seed_data(pydynox_memory_backend_factory):
"""Test with pre-populated data."""
seed = {
"users": [
{"pk": "USER#1", "name": "Alice", "age": 30},
{"pk": "USER#2", "name": "Bob", "age": 25},
]
}
with pydynox_memory_backend_factory(seed=seed):
# Data is already there!
alice = await User.get(pk="USER#1")
assert alice.name == "Alice"
bob = await User.get(pk="USER#2")
assert bob.name == "Bob"
@pytest.mark.asyncio
async def test_with_multiple_tables(pydynox_memory_backend_factory):
"""Seed multiple tables at once."""
seed = {
"users": [{"pk": "USER#1", "name": "John", "age": 30}],
"orders": [
{"pk": "USER#1", "sk": "ORDER#001", "total": 100},
{"pk": "USER#1", "sk": "ORDER#002", "total": 200},
],
}
with pydynox_memory_backend_factory(seed=seed):
user = await User.get(pk="USER#1")
assert user is not None
orders = [order async for order in Order.query(partition_key="USER#1")]
assert len(orders) == 2
# Alternative: use pydynox_seed fixture in conftest.py
@pytest.fixture
def pydynox_seed():
"""Override this in conftest.py to provide default seed data."""
return {
"users": [
{"pk": "ADMIN#1", "name": "Admin", "age": 99},
]
}
@pytest.mark.asyncio
async def test_with_seeded_fixture(pydynox_memory_backend_seeded):
"""Uses seed data from pydynox_seed fixture."""
admin = await User.get(pk="ADMIN#1")
assert admin.name == "Admin"
pydynox_memory_backend_seeded
For shared seed data across many tests, override pydynox_seed in your conftest.py:
# conftest.py
import pytest
@pytest.fixture
def pydynox_seed():
return {
"users": [
{"pk": "ADMIN#1", "name": "Admin", "role": "admin"},
{"pk": "USER#1", "name": "Test User", "role": "user"},
]
}
Then use pydynox_memory_backend_seeded in your tests:
import pytest
@pytest.mark.asyncio
async def test_admin_exists(pydynox_memory_backend_seeded):
admin = await User.get(pk="ADMIN#1")
assert admin.role == "admin"
Inspecting data
Access the backend to inspect stored data:
"""Inspecting data in the memory backend (async - default)."""
import pytest
from pydynox import Model, ModelConfig
from pydynox.attributes import NumberAttribute, StringAttribute
class User(Model):
model_config = ModelConfig(table="users")
pk = StringAttribute(partition_key=True)
name = StringAttribute()
age = NumberAttribute(default=0)
@pytest.mark.asyncio
async def test_inspect_tables(pydynox_memory_backend):
"""Access the backend to inspect stored data."""
await User(pk="USER#1", name="Alice").save()
await User(pk="USER#2", name="Bob").save()
# Access tables directly
assert "users" in pydynox_memory_backend.tables
assert len(pydynox_memory_backend.tables["users"]) == 2
# Check specific items
items = pydynox_memory_backend.tables["users"]
pks = [item["pk"] for item in items.values()]
assert "USER#1" in pks
assert "USER#2" in pks
@pytest.mark.asyncio
async def test_clear_data(pydynox_memory_backend):
"""Clear data mid-test for fresh state."""
await User(pk="USER#1", name="Test").save()
assert await User.get(pk="USER#1") is not None
# Clear all data
pydynox_memory_backend.clear()
# Now it's gone
assert await User.get(pk="USER#1") is None
@pytest.mark.asyncio
async def test_isolation_between_tests(pydynox_memory_backend):
"""Each test starts with empty tables."""
# This test doesn't see data from other tests
assert await User.get(pk="USER#1") is None
# Create data for this test only
await User(pk="USER#1", name="Isolated").save()
assert await User.get(pk="USER#1") is not None
Query and scan
The memory backend supports query and scan with filters:
"""Testing query and scan operations (async - default)."""
import pytest
from pydynox import Model, ModelConfig
from pydynox.attributes import NumberAttribute, StringAttribute
from pydynox.conditions import Attr
class Order(Model):
model_config = ModelConfig(table="orders")
pk = StringAttribute(partition_key=True)
sk = StringAttribute(sort_key=True)
status = StringAttribute()
total = NumberAttribute()
@pytest.mark.asyncio
async def test_query_by_partition_key(pydynox_memory_backend):
"""Test querying items by partition key."""
await Order(pk="USER#1", sk="ORDER#001", status="pending", total=100).save()
await Order(pk="USER#1", sk="ORDER#002", status="shipped", total=200).save()
await Order(pk="USER#2", sk="ORDER#001", status="pending", total=50).save()
# Query returns only USER#1's orders
results = [order async for order in Order.query(partition_key="USER#1")]
assert len(results) == 2
@pytest.mark.asyncio
async def test_query_with_filter(pydynox_memory_backend):
"""Test query with filter condition."""
await Order(pk="USER#1", sk="ORDER#001", status="pending", total=100).save()
await Order(pk="USER#1", sk="ORDER#002", status="shipped", total=200).save()
await Order(pk="USER#1", sk="ORDER#003", status="pending", total=300).save()
# Filter by status
results = [
order
async for order in Order.query(
partition_key="USER#1",
filter_condition=Attr("status").eq("pending"),
)
]
assert len(results) == 2
@pytest.mark.asyncio
async def test_scan_all_items(pydynox_memory_backend):
"""Test scanning all items in a table."""
await Order(pk="USER#1", sk="ORDER#001", status="pending", total=100).save()
await Order(pk="USER#2", sk="ORDER#001", status="shipped", total=200).save()
await Order(pk="USER#3", sk="ORDER#001", status="pending", total=300).save()
results = [order async for order in Order.scan()]
assert len(results) == 3
@pytest.mark.asyncio
async def test_scan_with_filter(pydynox_memory_backend):
"""Test scan with filter condition."""
await Order(pk="USER#1", sk="ORDER#001", status="pending", total=100).save()
await Order(pk="USER#2", sk="ORDER#001", status="shipped", total=200).save()
await Order(pk="USER#3", sk="ORDER#001", status="pending", total=300).save()
# Filter by total > 150
results = [order async for order in Order.scan(filter_condition=Attr("total").gt(150))]
assert len(results) == 2
Testing Lambda handlers
Perfect for testing AWS Lambda functions:
"""Testing AWS Lambda handlers with pydynox."""
import asyncio
from pydynox import Model, ModelConfig
from pydynox.attributes import StringAttribute
class User(Model):
model_config = ModelConfig(table="users")
pk = StringAttribute(partition_key=True)
name = StringAttribute()
# Lambda handlers are sync, but can run async code inside
def create_user_handler(event, context):
"""Lambda handler that creates a user."""
async def _create():
user_id = event["user_id"]
name = event["name"]
user = User(pk=f"USER#{user_id}", name=name)
await user.save()
return {"statusCode": 201, "body": f"Created user {user_id}"}
return asyncio.run(_create())
def get_user_handler(event, context):
"""Lambda handler that gets a user."""
async def _get():
user_id = event["user_id"]
user = await User.get(pk=f"USER#{user_id}")
if not user:
return {"statusCode": 404, "body": "User not found"}
return {"statusCode": 200, "body": {"name": user.name}}
return asyncio.run(_get())
# Tests - no moto, no localstack, no DynamoDB Local!
def test_create_user_handler(pydynox_memory_backend):
"""Test the create user Lambda handler."""
event = {"user_id": "123", "name": "John"}
response = create_user_handler(event, None)
assert response["statusCode"] == 201
# Verify user was created (sync in test)
user = User.sync_get(pk="USER#123")
assert user is not None
assert user.name == "John"
def test_get_user_handler(pydynox_memory_backend):
"""Test the get user Lambda handler."""
# Setup: create a user first
User(pk="USER#123", name="Jane").sync_save()
event = {"user_id": "123"}
response = get_user_handler(event, None)
assert response["statusCode"] == 200
assert response["body"]["name"] == "Jane"
def test_get_user_not_found(pydynox_memory_backend):
"""Test getting a non-existent user."""
event = {"user_id": "999"}
response = get_user_handler(event, None)
assert response["statusCode"] == 404
No mocking needed. Your handler code runs exactly as it would in production, just with in-memory storage.
Without pytest
You can use MemoryBackend directly as a context manager or decorator:
"""Using MemoryBackend as context manager (without pytest)."""
import asyncio
from pydynox import Model, ModelConfig
from pydynox.attributes import StringAttribute
from pydynox.testing import MemoryBackend
class User(Model):
model_config = ModelConfig(table="users")
pk = StringAttribute(partition_key=True)
name = StringAttribute()
# Use as context manager (async)
async def main():
with MemoryBackend() as backend:
# All pydynox operations use in-memory storage
user = User(pk="USER#1", name="John")
await user.save()
found = await User.get(pk="USER#1")
print(f"Found: {found.name}") # Output: Found: John
# Inspect the data
print(f"Tables: {list(backend.tables.keys())}")
print(f"Items: {len(backend.tables['users'])}")
# Use with seed data (async)
async def test_with_seed():
seed = {"users": [{"pk": "USER#1", "name": "Seeded"}]}
with MemoryBackend(seed=seed):
user = await User.get(pk="USER#1")
assert user.name == "Seeded"
print("Seed test passed!")
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(test_with_seed())
print("All tests passed!")
Supported operations
The memory backend supports:
| Operation | Supported |
|---|---|
save() |
✓ |
get() |
✓ |
delete() |
✓ |
update() |
✓ |
query() |
✓ |
scan() |
✓ |
batch_write() |
✓ |
batch_get() |
✓ |
| Conditions | ✓ |
| Atomic updates | ✓ |
Note
Some advanced features like transactions and GSI queries are not yet supported in the memory backend. Use localstack for those cases.
Comparison with alternatives
| Feature | pydynox fixture | moto | localstack |
|---|---|---|---|
| Setup | None | Decorator | Docker |
| Speed | Fastest | Fast | Slow |
| Accuracy | Good | Good | Best |
| Dependencies | None | moto | Docker |
| GSI support | No | Yes | Yes |
| Transactions | No | Yes | Yes |
Use pydynox fixtures for:
- Unit tests
- Fast feedback loops
- CI/CD pipelines
- Simple CRUD tests
Use localstack for:
- Integration tests
- GSI queries
- Transactions
- Full DynamoDB compatibility
Tips
Run tests in parallel
The memory backend is isolated per test, so parallel execution works:
Combine with parametrize
import pytest
@pytest.mark.asyncio
@pytest.mark.parametrize("name,age", [
("Alice", 30),
("Bob", 25),
("Charlie", 35),
])
async def test_create_users(pydynox_memory_backend, name, age):
user = User(pk=f"USER#{name}", name=name, age=age)
await user.save()
found = await User.get(pk=f"USER#{name}")
assert found.age == age
Use autouse for all tests
If you want all tests to use the memory backend:
# conftest.py
import pytest
@pytest.fixture(autouse=True)
def use_memory_backend(pydynox_memory_backend):
yield
Now every test automatically uses in-memory storage.
Next steps
- Models - Define your data models
- Query - Query items by key
- Conditions - Conditional operations