Testing

Phase 1 Complete - 90%+ Coverage

Table of contents

  1. Testing Philosophy
    1. Testing Pyramid
  2. Current Test Coverage
    1. Running Tests
    2. Test Infrastructure
  3. In-Memory Test Store
  4. BDD-Style Test Fixtures
    1. Test Fixture API
    2. BDD Test Examples
  5. Projection Testing
  6. Event Assertions
  7. Event Diffing
  8. Saga Testing
  9. Integration Test Helpers
  10. Test Containers

Testing Philosophy

Mink provides comprehensive testing utilities to make event-sourced systems easy to test.

Testing Pyramid

         ╱╲
        ╱  ╲         E2E Tests (few)
       ╱────╲        - Full system integration
      ╱      ╲       - Database, projections, sagas
     ╱────────╲      
    ╱          ╲     Integration Tests (some)
   ╱────────────╲    - Adapter tests
  ╱              ╲   - Projection tests
 ╱────────────────╲  
╱                  ╲ Unit Tests (many)
╱────────────────────╲
                      - Aggregate logic
                      - Command validation
                      - Event handlers

Current Test Coverage

Package Coverage
mink (core) 95.3% ✅
adapters/memory 94.2% ✅
adapters/postgres 86.4% ✅
Required Threshold 90%

Running Tests

# Unit tests only (no infrastructure required)
make test-unit

# All tests with infrastructure
make test

# Tests with coverage report
make test-coverage

Test Infrastructure

All test infrastructure is defined in docker-compose.test.yml - the single source of truth for both local development and CI:

# Start infrastructure
make infra-up

# Stop infrastructure  
make infra-down

In-Memory Test Store

package minktest

import "github.com/AshkanYarmoradi/go-mink"

// NewInMemoryStore creates a test event store
func NewInMemoryStore() *mink.EventStore {
    return mink.New(memory.NewAdapter())
}

// NewInMemoryStoreWithEvents pre-populates events
func NewInMemoryStoreWithEvents(events map[string][]mink.Event) *mink.EventStore {
    adapter := memory.NewAdapter()
    for streamID, evts := range events {
        adapter.Append(context.Background(), streamID, evts, mink.AnyVersion)
    }
    return mink.New(adapter)
}

// Usage
func TestOrderAggregate(t *testing.T) {
    store := minktest.NewInMemoryStore()
    
    order := NewOrder("order-123")
    order.Create("customer-456")
    order.AddItem("SKU-001", 2, 29.99)
    
    err := store.SaveAggregate(context.Background(), order)
    assert.NoError(t, err)
    
    // Reload and verify
    loaded := NewOrder("order-123")
    store.LoadAggregate(context.Background(), loaded)
    
    assert.Equal(t, "Created", loaded.Status)
    assert.Equal(t, 1, len(loaded.Items))
}

BDD-Style Test Fixtures

Write expressive tests using Given-When-Then pattern.

Test Fixture API

package minktest

// TestFixture provides BDD-style testing
type TestFixture struct {
    t          *testing.T
    store      *mink.EventStore
    aggregate  mink.Aggregate
    givenEvents []interface{}
    command    mink.Command
    err        error
}

// Given sets up initial events
func Given(t *testing.T, aggregate mink.Aggregate, events ...interface{}) *TestFixture {
    return &TestFixture{
        t:           t,
        store:       NewInMemoryStore(),
        aggregate:   aggregate,
        givenEvents: events,
    }
}

// When executes a command
func (f *TestFixture) When(cmd mink.Command) *TestFixture {
    // Apply given events
    for _, event := range f.givenEvents {
        f.aggregate.ApplyEvent(event)
    }
    f.aggregate.ClearUncommittedEvents()
    
    // Execute command
    f.command = cmd
    f.err = f.executeCommand(cmd)
    
    return f
}

// Then asserts expected events
func (f *TestFixture) Then(expectedEvents ...interface{}) {
    f.t.Helper()
    
    if f.err != nil {
        f.t.Fatalf("Expected success but got error: %v", f.err)
    }
    
    uncommitted := f.aggregate.UncommittedEvents()
    if len(uncommitted) != len(expectedEvents) {
        f.t.Fatalf("Expected %d events, got %d", len(expectedEvents), len(uncommitted))
    }
    
    for i, expected := range expectedEvents {
        if !reflect.DeepEqual(uncommitted[i], expected) {
            f.t.Errorf("Event %d mismatch:\nExpected: %+v\nActual: %+v", 
                i, expected, uncommitted[i])
        }
    }
}

// ThenError asserts expected error
func (f *TestFixture) ThenError(expectedErr error) {
    f.t.Helper()
    
    if f.err == nil {
        f.t.Fatal("Expected error but got success")
    }
    
    if !errors.Is(f.err, expectedErr) {
        f.t.Errorf("Expected error %v, got %v", expectedErr, f.err)
    }
}

// ThenErrorContains checks error message
func (f *TestFixture) ThenErrorContains(substring string) {
    f.t.Helper()
    
    if f.err == nil {
        f.t.Fatal("Expected error but got success")
    }
    
    if !strings.Contains(f.err.Error(), substring) {
        f.t.Errorf("Expected error containing %q, got %q", substring, f.err.Error())
    }
}

// ThenNoEvents asserts no events produced
func (f *TestFixture) ThenNoEvents() {
    f.t.Helper()
    
    uncommitted := f.aggregate.UncommittedEvents()
    if len(uncommitted) > 0 {
        f.t.Errorf("Expected no events, got %d: %+v", len(uncommitted), uncommitted)
    }
}

BDD Test Examples

func TestOrderCanBeCreated(t *testing.T) {
    Given(t, NewOrder("order-123")).
        When(CreateOrderCommand{
            OrderID:    "order-123",
            CustomerID: "customer-456",
        }).
        Then(
            OrderCreated{
                OrderID:    "order-123",
                CustomerID: "customer-456",
            },
        )
}

func TestCannotAddItemToShippedOrder(t *testing.T) {
    Given(t, NewOrder("order-123"),
        OrderCreated{OrderID: "order-123", CustomerID: "customer-456"},
        ItemAdded{SKU: "SKU-001", Quantity: 1, Price: 29.99},
        OrderShipped{OrderID: "order-123"},
    ).
        When(AddItemCommand{
            OrderID:  "order-123",
            SKU:      "SKU-002",
            Quantity: 1,
        }).
        ThenError(ErrOrderAlreadyShipped)
}

func TestItemQuantityMustBePositive(t *testing.T) {
    Given(t, NewOrder("order-123"),
        OrderCreated{OrderID: "order-123", CustomerID: "customer-456"},
    ).
        When(AddItemCommand{
            OrderID:  "order-123",
            SKU:      "SKU-001",
            Quantity: 0, // Invalid
        }).
        ThenErrorContains("quantity must be positive")
}

func TestMultipleItemsCanBeAdded(t *testing.T) {
    Given(t, NewOrder("order-123"),
        OrderCreated{OrderID: "order-123", CustomerID: "customer-456"},
    ).
        When(AddItemsCommand{
            OrderID: "order-123",
            Items: []Item{
                {SKU: "SKU-001", Quantity: 2, Price: 29.99},
                {SKU: "SKU-002", Quantity: 1, Price: 49.99},
            },
        }).
        Then(
            ItemAdded{SKU: "SKU-001", Quantity: 2, Price: 29.99},
            ItemAdded{SKU: "SKU-002", Quantity: 1, Price: 49.99},
        )
}

Projection Testing

package minktest

// ProjectionTestFixture tests projections
type ProjectionTestFixture[T any] struct {
    t          *testing.T
    projection mink.Projection
    repo       mink.Repository[T]
}

// TestProjection creates projection test fixture
func TestProjection[T any](t *testing.T, projection mink.Projection) *ProjectionTestFixture[T] {
    return &ProjectionTestFixture[T]{
        t:          t,
        projection: projection,
        repo:       memory.NewRepository[T](),
    }
}

// GivenEvents applies events to projection
func (f *ProjectionTestFixture[T]) GivenEvents(events ...mink.Event) *ProjectionTestFixture[T] {
    for _, event := range events {
        f.projection.Apply(context.Background(), nil, event)
    }
    return f
}

// ThenReadModel asserts read model state
func (f *ProjectionTestFixture[T]) ThenReadModel(id string, expected T) {
    f.t.Helper()
    
    actual, err := f.repo.Get(context.Background(), id)
    if err != nil {
        f.t.Fatalf("Failed to get read model: %v", err)
    }
    
    if !reflect.DeepEqual(*actual, expected) {
        f.t.Errorf("Read model mismatch:\nExpected: %+v\nActual: %+v", expected, *actual)
    }
}

// Usage
func TestOrderSummaryProjection(t *testing.T) {
    TestProjection[OrderSummary](t, &OrderSummaryProjection{}).
        GivenEvents(
            Event{Type: "OrderCreated", Data: OrderCreated{
                OrderID: "order-123", CustomerID: "cust-456",
            }},
            Event{Type: "ItemAdded", Data: ItemAdded{
                OrderID: "order-123", SKU: "WIDGET", Quantity: 2, Price: 29.99,
            }},
        ).
        ThenReadModel("order-123", OrderSummary{
            ID:          "order-123",
            CustomerID:  "cust-456",
            Status:      "Created",
            ItemCount:   2,
            TotalAmount: 59.98,
        })
}

Event Assertions

package minktest

// AssertEventTypes checks event types match
func AssertEventTypes(t *testing.T, events []interface{}, types ...string) {
    t.Helper()
    
    if len(events) != len(types) {
        t.Fatalf("Expected %d events, got %d", len(types), len(events))
    }
    
    for i, expectedType := range types {
        actualType := reflect.TypeOf(events[i]).Name()
        if actualType != expectedType {
            t.Errorf("Event %d: expected type %s, got %s", i, expectedType, actualType)
        }
    }
}

// AssertEventData checks specific event data
func AssertEventData[T any](t *testing.T, event interface{}, expected T) {
    t.Helper()
    
    actual, ok := event.(T)
    if !ok {
        t.Fatalf("Event is not of expected type %T", expected)
    }
    
    if !reflect.DeepEqual(actual, expected) {
        t.Errorf("Event data mismatch:\nExpected: %+v\nActual: %+v", expected, actual)
    }
}

// AssertStreamVersion checks stream version
func AssertStreamVersion(t *testing.T, store *mink.EventStore, streamID string, expected int64) {
    t.Helper()
    
    info, err := store.GetStreamInfo(context.Background(), streamID)
    if err != nil {
        t.Fatalf("Failed to get stream info: %v", err)
    }
    
    if info.Version != expected {
        t.Errorf("Stream %s: expected version %d, got %d", streamID, expected, info.Version)
    }
}

// AssertNoEvents checks no uncommitted events
func AssertNoEvents(t *testing.T, agg mink.Aggregate) {
    t.Helper()
    
    events := agg.UncommittedEvents()
    if len(events) > 0 {
        t.Errorf("Expected no uncommitted events, got %d", len(events))
    }
}

// Usage
func TestOrderCreation(t *testing.T) {
    order := NewOrder("order-123")
    order.Create("customer-456")
    order.AddItem("SKU-001", 2, 29.99)
    
    minktest.AssertEventTypes(t, order.UncommittedEvents(),
        "OrderCreated", "ItemAdded")
    
    minktest.AssertEventData(t, order.UncommittedEvents()[0], OrderCreated{
        OrderID:    "order-123",
        CustomerID: "customer-456",
    })
}

Event Diffing

package minktest

// DiffEvents shows differences between event slices
func DiffEvents(t *testing.T, expected, actual []interface{}) {
    t.Helper()
    
    maxLen := len(expected)
    if len(actual) > maxLen {
        maxLen = len(actual)
    }
    
    var diffs []string
    for i := 0; i < maxLen; i++ {
        var exp, act interface{}
        if i < len(expected) {
            exp = expected[i]
        }
        if i < len(actual) {
            act = actual[i]
        }
        
        if !reflect.DeepEqual(exp, act) {
            diffs = append(diffs, formatDiff(i, exp, act))
        }
    }
    
    if len(diffs) > 0 {
        t.Errorf("Event differences:\n%s", strings.Join(diffs, "\n"))
    }
}

func formatDiff(index int, expected, actual interface{}) string {
    var buf strings.Builder
    buf.WriteString(fmt.Sprintf("Event %d:\n", index))
    
    if expected == nil {
        buf.WriteString(fmt.Sprintf("  + %T %+v (unexpected)\n", actual, actual))
    } else if actual == nil {
        buf.WriteString(fmt.Sprintf("  - %T %+v (missing)\n", expected, expected))
    } else {
        buf.WriteString(fmt.Sprintf("  - %T %+v\n", expected, expected))
        buf.WriteString(fmt.Sprintf("  + %T %+v\n", actual, actual))
    }
    
    return buf.String()
}

// Usage
func TestWithDiff(t *testing.T) {
    expected := []interface{}{
        OrderCreated{OrderID: "123"},
        ItemAdded{SKU: "WIDGET", Quantity: 2},
    }
    
    actual := []interface{}{
        OrderCreated{OrderID: "123"},
        ItemAdded{SKU: "WIDGET", Quantity: 3}, // Wrong quantity
    }
    
    minktest.DiffEvents(t, expected, actual)
    // Output:
    // Event 1:
    //   - ItemAdded{SKU: "WIDGET", Quantity: 2}
    //   + ItemAdded{SKU: "WIDGET", Quantity: 3}
}

Saga Testing

package minktest

// SagaTestFixture tests saga behavior
type SagaTestFixture struct {
    t          *testing.T
    saga       mink.Saga
    commands   []mink.Command
    err        error
}

func TestSaga(t *testing.T, saga mink.Saga) *SagaTestFixture {
    return &SagaTestFixture{t: t, saga: saga}
}

func (f *SagaTestFixture) GivenEvents(events ...mink.Event) *SagaTestFixture {
    for _, event := range events {
        cmds, err := f.saga.HandleEvent(context.Background(), event)
        if err != nil {
            f.err = err
            return f
        }
        f.commands = append(f.commands, cmds...)
    }
    return f
}

func (f *SagaTestFixture) ThenCommands(expected ...mink.Command) {
    f.t.Helper()
    
    if len(f.commands) != len(expected) {
        f.t.Fatalf("Expected %d commands, got %d", len(expected), len(f.commands))
    }
    
    for i, exp := range expected {
        if !reflect.DeepEqual(f.commands[i], exp) {
            f.t.Errorf("Command %d mismatch:\nExpected: %+v\nActual: %+v",
                i, exp, f.commands[i])
        }
    }
}

func (f *SagaTestFixture) ThenCompleted() {
    f.t.Helper()
    if !f.saga.IsComplete() {
        f.t.Error("Expected saga to be complete")
    }
}

// Usage
func TestOrderFulfillmentSaga(t *testing.T) {
    saga := NewOrderFulfillmentSaga("saga-123")
    
    TestSaga(t, saga).
        GivenEvents(
            Event{Type: "OrderCreated", Data: OrderCreated{OrderID: "order-123"}},
        ).
        ThenCommands(
            RequestPayment{OrderID: "order-123"},
        )
    
    TestSaga(t, saga).
        GivenEvents(
            Event{Type: "PaymentReceived", Data: PaymentReceived{OrderID: "order-123"}},
        ).
        ThenCommands(
            ReserveInventory{OrderID: "order-123"},
        )
}

Integration Test Helpers

package minktest

// IntegrationTest provides real database testing
type IntegrationTest struct {
    t      *testing.T
    store  *mink.EventStore
    db     *sql.DB
}

func NewIntegrationTest(t *testing.T, connStr string) *IntegrationTest {
    db, err := sql.Open("pgx", connStr)
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    
    adapter := postgres.NewAdapter(db)
    store := mink.New(adapter)
    
    // Create test schema
    testSchema := fmt.Sprintf("test_%d", time.Now().UnixNano())
    db.Exec(fmt.Sprintf("CREATE SCHEMA %s", testSchema))
    
    it := &IntegrationTest{t: t, store: store, db: db}
    
    // Cleanup on test end
    t.Cleanup(func() {
        db.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", testSchema))
        db.Close()
    })
    
    return it
}

func (it *IntegrationTest) Store() *mink.EventStore {
    return it.store
}

// Test with real PostgreSQL
func TestWithRealDatabase(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping integration test")
    }
    
    it := minktest.NewIntegrationTest(t, os.Getenv("TEST_DATABASE_URL"))
    
    order := NewOrder("order-123")
    order.Create("customer-456")
    
    err := it.Store().SaveAggregate(context.Background(), order)
    assert.NoError(t, err)
    
    // Verify in database
    loaded := NewOrder("order-123")
    it.Store().LoadAggregate(context.Background(), loaded)
    assert.Equal(t, "customer-456", loaded.CustomerID)
}

Test Containers

package minktest

import (
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

// PostgresContainer provides PostgreSQL for tests
func PostgresContainer(t *testing.T) (string, func()) {
    ctx := context.Background()
    
    container, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:15-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    if err != nil {
        t.Fatalf("Failed to start container: %v", err)
    }
    
    connStr, _ := container.ConnectionString(ctx, "sslmode=disable")
    
    cleanup := func() {
        container.Terminate(ctx)
    }
    
    return connStr, cleanup
}

// Usage
func TestWithContainer(t *testing.T) {
    connStr, cleanup := minktest.PostgresContainer(t)
    defer cleanup()
    
    adapter := postgres.NewAdapter(connStr)
    store := mink.New(adapter)
    
    // Run tests...
}

Next: Security →