Skip to content

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:

export DYNAMODB_ENDPOINT=http://localhost:8000
go test ./...
// 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