Testing¶
Dynorm provides comprehensive utilities for testing your entities and repositories, including first-class support for DynamoDB Local.
DynamoDB Local¶
DynamoDB Local is a downloadable version of DynamoDB that lets you develop and test applications without accessing the DynamoDB web service. Dynorm provides built-in support for DynamoDB Local.
Quick Setup¶
import (
"os"
"testing"
"github.com/go-gamma/dynorm"
"github.com/go-gamma/dynorm/pkg/dynormtest/local"
)
func TestMain(m *testing.M) {
// Setup DynamoDB Local
local.MustSetup("http://localhost:8000")
defer local.Teardown()
os.Exit(m.Run())
}
func TestCreateUser(t *testing.T) {
// Create table for your entity
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
// Your test code here
user := &User{
Email: "test@example.com",
Name: "Test User",
}
err := UserRepo.Save(user)
if err != nil {
t.Fatalf("Save failed: %v", err)
}
// Verify
found, _ := UserRepo.Find(user.ID)
if found.Email != user.Email {
t.Errorf("expected %s, got %s", user.Email, found.Email)
}
}
Environment Variable¶
You can also configure the endpoint via environment variable:
// In your code - endpoint is automatically picked up
func TestMain(m *testing.M) {
// SetEndpoint not needed if DYNAMODB_ENDPOINT is set
os.Exit(m.Run())
}
Programmatic Configuration¶
import "github.com/go-gamma/dynorm"
func TestMain(m *testing.M) {
// Set endpoint programmatically
dynorm.SetEndpoint("http://localhost:8000")
defer dynorm.Reset()
os.Exit(m.Run())
}
Local Testing Package¶
The dynormtest/local package provides utilities for working with DynamoDB Local.
Setup and Teardown¶
import "github.com/go-gamma/dynorm/pkg/dynormtest/local"
func TestMain(m *testing.M) {
// Setup with default endpoint (http://localhost:8000)
err := local.Setup("")
if err != nil {
log.Fatal(err)
}
defer local.Teardown()
// Or use MustSetup for panic on error
local.MustSetup("http://localhost:8000")
defer local.Teardown()
os.Exit(m.Run())
}
Creating Tables¶
Create tables from your entity definitions:
func TestUserCRUD(t *testing.T) {
// Create table - uses entity schema
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
// Table is now ready with correct schema:
// - Primary key
// - All GSIs
// - Correct attribute types
}
Clearing Tables¶
Reset table data between tests without recreating:
func TestUserOperations(t *testing.T) {
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
t.Run("create user", func(t *testing.T) {
// ... create user test
})
// Clear all data before next test
local.MustClearTable(t, "users", "id", "")
t.Run("query users", func(t *testing.T) {
// Start fresh
})
}
For tables with composite keys:
// Clear table with partition and sort key
local.MustClearTable(t, "order_items", "order_id", "product_id")
Direct Client Access¶
Access the DynamoDB client for custom operations:
func TestCustomOperation(t *testing.T) {
local.MustSetup("http://localhost:8000")
defer local.Teardown()
client := local.Client()
// Use client directly for custom operations
result, err := client.ListTables(context.Background(), &dynamodb.ListTablesInput{})
if err != nil {
t.Fatal(err)
}
fmt.Printf("Tables: %v\n", result.TableNames)
}
Check Table Existence¶
func TestTableOperations(t *testing.T) {
local.MustSetup("")
defer local.Teardown()
// Check if table exists
exists, err := local.TableExists(context.Background(), "users")
if err != nil {
t.Fatal(err)
}
if !exists {
local.MustCreateTable(t, "users", (*User)(nil))
}
}
Test Isolation¶
Per-Test Table Creation¶
For complete isolation, create tables per test:
func TestUserSave(t *testing.T) {
local.MustSetup("")
defer local.Teardown()
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
// Test runs with fresh table
user := &User{Email: "test@example.com"}
err := UserRepo.Save(user)
// ...
}
func TestUserQuery(t *testing.T) {
local.MustSetup("")
defer local.Teardown()
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
// Completely isolated from TestUserSave
}
Table Prefix Isolation¶
Use prefixes to run tests in parallel:
func TestMain(m *testing.M) {
local.MustSetup("")
// Unique prefix per test run
prefix := fmt.Sprintf("test-%d-", time.Now().UnixNano())
dynorm.SetTablePrefix(prefix)
code := m.Run()
// Cleanup: delete all tables with prefix
cleanupTables(prefix)
local.Teardown()
os.Exit(code)
}
Mock Client¶
For pure unit tests without DynamoDB Local:
Using SetClient¶
import (
"testing"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/go-gamma/dynorm"
)
func TestMain(m *testing.M) {
// Set up mock client
mockClient := &MockDynamoDBClient{}
dynorm.SetClient(mockClient)
code := m.Run()
dynorm.Reset()
os.Exit(code)
}
Simple Mock Implementation¶
type MockDynamoDBClient struct {
items map[string]map[string]types.AttributeValue
mu sync.Mutex
}
func NewMockClient() *MockDynamoDBClient {
return &MockDynamoDBClient{
items: make(map[string]map[string]types.AttributeValue),
}
}
func (m *MockDynamoDBClient) PutItem(
ctx context.Context,
params *dynamodb.PutItemInput,
optFns ...func(*dynamodb.Options),
) (*dynamodb.PutItemOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := *params.TableName + ":" + getKeyValue(params.Item)
m.items[key] = params.Item
return &dynamodb.PutItemOutput{}, nil
}
func (m *MockDynamoDBClient) GetItem(
ctx context.Context,
params *dynamodb.GetItemInput,
optFns ...func(*dynamodb.Options),
) (*dynamodb.GetItemOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := *params.TableName + ":" + getKeyValue(params.Key)
item, exists := m.items[key]
if !exists {
return &dynamodb.GetItemOutput{}, nil
}
return &dynamodb.GetItemOutput{Item: item}, nil
}
Using testify/mock¶
import "github.com/stretchr/testify/mock"
type MockDynamoDBClient struct {
mock.Mock
}
func (m *MockDynamoDBClient) PutItem(
ctx context.Context,
params *dynamodb.PutItemInput,
optFns ...func(*dynamodb.Options),
) (*dynamodb.PutItemOutput, error) {
args := m.Called(ctx, params)
return args.Get(0).(*dynamodb.PutItemOutput), args.Error(1)
}
func TestSaveWithMock(t *testing.T) {
mockClient := new(MockDynamoDBClient)
mockClient.On("PutItem", mock.Anything, mock.Anything).Return(
&dynamodb.PutItemOutput{},
nil,
)
dynorm.SetClient(mockClient)
defer dynorm.Reset()
user := &User{Email: "test@example.com"}
err := UserRepo.Save(user)
assert.NoError(t, err)
mockClient.AssertExpectations(t)
}
Testing Patterns¶
Unit Testing Entities¶
import "github.com/go-gamma/dynorm/pkg/validation"
func TestUserValidation(t *testing.T) {
tests := []struct {
name string
user *User
wantErr bool
}{
{
name: "valid user",
user: &User{Email: "test@example.com", Name: "Test"},
wantErr: false,
},
{
name: "missing email",
user: &User{Name: "Test"},
wantErr: true,
},
{
name: "invalid email",
user: &User{Email: "not-an-email", Name: "Test"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validation.Validate(tt.user)
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
Testing Lifecycle Events¶
func TestUserCreatingEvent(t *testing.T) {
user := &User{
Email: "TEST@EXAMPLE.COM",
}
// Trigger Creating event
err := user.Creating()
if err != nil {
t.Fatalf("Creating failed: %v", err)
}
// Check email was normalized
if user.Email != "test@example.com" {
t.Errorf("expected normalized email, got %s", user.Email)
}
}
Integration Testing with DynamoDB Local¶
func TestUserCRUD(t *testing.T) {
local.MustSetup("")
defer local.Teardown()
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
t.Run("Create", func(t *testing.T) {
user := &User{
Email: "create@example.com",
Name: "Create Test",
}
err := UserRepo.Save(user)
if err != nil {
t.Fatalf("Save failed: %v", err)
}
if user.ID == "" {
t.Error("expected ID to be set")
}
})
t.Run("Read", func(t *testing.T) {
// Create user first
user := &User{Email: "read@example.com", Name: "Read Test"}
UserRepo.Save(user)
// Read back
found, err := UserRepo.Find(user.ID)
if err != nil {
t.Fatalf("Find failed: %v", err)
}
if found.Email != user.Email {
t.Errorf("expected %s, got %s", user.Email, found.Email)
}
})
t.Run("Update", func(t *testing.T) {
user := &User{Email: "update@example.com", Name: "Original"}
UserRepo.Save(user)
user.Name = "Updated"
err := UserRepo.Save(user)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
found, _ := UserRepo.Find(user.ID)
if found.Name != "Updated" {
t.Errorf("expected Updated, got %s", found.Name)
}
})
t.Run("Delete", func(t *testing.T) {
user := &User{Email: "delete@example.com", Name: "Delete Test"}
UserRepo.Save(user)
err := UserRepo.Delete(user)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
found, _ := UserRepo.Find(user.ID)
if found != nil {
t.Error("expected user to be deleted")
}
})
}
Testing Queries with GSIs¶
func TestUserQueries(t *testing.T) {
local.MustSetup("")
defer local.Teardown()
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
// Setup test data
users := []*User{
{Email: "active1@example.com", Status: "active"},
{Email: "active2@example.com", Status: "active"},
{Email: "inactive@example.com", Status: "inactive"},
}
for _, u := range users {
UserRepo.Save(u)
}
t.Run("Query by status", func(t *testing.T) {
result, err := UserRepo.
Where("Status", "=", "active").
GetAll()
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if result.Count() != 2 {
t.Errorf("expected 2 active users, got %d", result.Count())
}
})
t.Run("Query by email", func(t *testing.T) {
result, err := UserRepo.
Where("Email", "=", "active1@example.com").
GetFirst()
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if result == nil {
t.Error("expected to find user")
}
})
}
Testing Optimistic Locking¶
func TestOptimisticLocking(t *testing.T) {
local.MustSetup("")
defer local.Teardown()
local.MustCreateTable(t, "users", (*User)(nil))
defer local.MustDeleteTable(t, "users")
// Create user
user := &User{Email: "lock@example.com"}
UserRepo.Save(user)
// Simulate concurrent read
user2, _ := UserRepo.Find(user.ID)
// First update succeeds
user.Name = "First Update"
err := UserRepo.Save(user)
if err != nil {
t.Fatalf("First save failed: %v", err)
}
// Second update should fail (version mismatch)
user2.Name = "Second Update"
err = UserRepo.Save(user2)
if !dynormerrors.IsVersionMismatch(err) {
t.Errorf("expected version mismatch error, got %v", err)
}
}
Docker Compose¶
version: '3.8'
services:
dynamodb-local:
image: amazon/dynamodb-local:latest
ports:
- "8000:8000"
command: "-jar DynamoDBLocal.jar -sharedDb -inMemory"
# Start DynamoDB Local
docker-compose up -d
# Run tests
DYNAMODB_ENDPOINT=http://localhost:8000 go test ./...
# Stop DynamoDB Local
docker-compose down
GitHub Actions¶
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
dynamodb:
image: amazon/dynamodb-local:latest
ports:
- 8000:8000
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Run tests
env:
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: dummy
AWS_SECRET_ACCESS_KEY: dummy
DYNAMODB_ENDPOINT: http://localhost:8000
run: go test -v -race ./...
Makefile¶
.PHONY: test test-local
# Start DynamoDB Local and run tests
test-local:
docker-compose up -d
DYNAMODB_ENDPOINT=http://localhost:8000 go test -v ./...
docker-compose down
# Run tests with existing DynamoDB Local
test:
DYNAMODB_ENDPOINT=http://localhost:8000 go test -v ./...
Best Practices¶
Do
- Use DynamoDB Local for integration tests
- Reset state between tests with
defer local.MustDeleteTable() - Use table-driven tests for comprehensive coverage
- Test error conditions and edge cases
- Use unique prefixes for parallel test runs
- Clean up test data in teardown
Don't
- Share mutable state between tests
- Use production credentials in tests
- Skip testing error paths
- Leave test data in shared environments
- Forget to defer cleanup functions
Performance Tip
For faster test runs, reuse tables across tests and clear data instead of recreating:
func TestMain(m *testing.M) {
local.MustSetup("")
defer local.Teardown()
// Create all tables once
local.MustCreateTable(nil, "users", (*User)(nil))
local.MustCreateTable(nil, "orders", (*Order)(nil))
os.Exit(m.Run())
}
func TestSomething(t *testing.T) {
// Clear data, don't recreate table
local.MustClearTable(t, "users", "id", "")
// ... test
}
API Reference¶
Local Package Functions¶
| Function | Description |
|---|---|
Setup(endpoint) | Configure for DynamoDB Local |
MustSetup(endpoint) | Setup or panic |
Teardown() | Reset configuration |
CreateTable(ctx, tableName, entityType) | Create table from entity |
MustCreateTable(t, tableName, entityType) | Create table or fail test |
DeleteTable(ctx, tableName) | Delete a table |
MustDeleteTable(t, tableName) | Delete table or fail test |
ClearTable(ctx, tableName, pkAttr, skAttr) | Clear all table data |
MustClearTable(t, tableName, pkAttr, skAttr) | Clear data or fail test |
TableExists(ctx, tableName) | Check if table exists |
Client() | Get the DynamoDB client |
Endpoint() | Get current endpoint |
Global Functions¶
| Function | Description |
|---|---|
dynorm.SetEndpoint(endpoint) | Set custom endpoint |
dynorm.GetEndpoint() | Get current endpoint |
dynorm.SetClient(client) | Set custom DynamoDB client |
dynorm.Reset() | Reset all global state |
dynorm.SetTablePrefix(prefix) | Set table name prefix |
Next Steps¶
- Configuration - Environment setup
- Repository Pattern - CRUD operations
- Schema Export - Generate infrastructure