Testing
Phase 4 Complete - Full Testing Utilities
Table of contents
- Testing Philosophy
- Testing Packages
- BDD-Style Testing
- Event Assertions
- Projection Testing
- Saga Testing
- Test Containers
- Mock Adapters
- Running Tests
- Best Practices
Testing Philosophy
Mink provides comprehensive testing utilities to make event-sourced systems easy to test. The testing packages follow BDD patterns and provide type-safe, expressive assertions.
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
Testing Packages
Mink v0.4.0 includes a comprehensive suite of testing utilities:
| Package | Purpose |
|---|---|
testing/bdd |
BDD-style Given-When-Then fixtures |
testing/assertions |
Event assertions and diffing |
testing/projections |
Projection testing helpers |
testing/sagas |
Saga testing fixtures |
testing/containers |
PostgreSQL test containers |
testing/testutil |
Mock adapters and helpers |
BDD-Style Testing
The testing/bdd package provides Given-When-Then style test fixtures.
Aggregate Testing
import "github.com/AshkanYarmoradi/go-mink/testing/bdd"
func TestOrderCanBeCreated(t *testing.T) {
order := NewOrder("order-123")
bdd.Given(t, order).
When(func() error {
return order.Create("customer-456")
}).
Then(
OrderCreated{
OrderID: "order-123",
CustomerID: "customer-456",
},
)
}
func TestCannotAddItemToShippedOrder(t *testing.T) {
order := NewOrder("order-123")
bdd.Given(t, order,
OrderCreated{OrderID: "order-123", CustomerID: "customer-456"},
OrderShipped{OrderID: "order-123"},
).
When(func() error {
return order.AddItem("SKU-001", 1, 29.99)
}).
ThenError(ErrOrderAlreadyShipped)
}
func TestErrorMessageContains(t *testing.T) {
order := NewOrder("order-123")
bdd.Given(t, order,
OrderCreated{OrderID: "order-123", CustomerID: "customer-456"},
).
When(func() error {
return order.AddItem("SKU-001", 0, 29.99) // Invalid quantity
}).
ThenErrorContains("quantity must be positive")
}
func TestNoEventsProduced(t *testing.T) {
order := NewOrder("order-123")
bdd.Given(t, order,
OrderCreated{OrderID: "order-123", CustomerID: "customer-456"},
).
When(func() error {
// Already created, no new events
return nil
}).
ThenNoEvents()
}
Command Bus Testing
func TestCommandBusIntegration(t *testing.T) {
bus := mink.NewCommandBus()
store := mink.New(memory.NewAdapter())
// Register handlers...
bdd.GivenCommand(t, bus, store).
WithContext(ctx).
WithExistingEvents("order-123",
OrderCreated{OrderID: "order-123", CustomerID: "cust-456"},
).
When(AddItemCommand{
OrderID: "order-123",
SKU: "SKU-001",
Quantity: 2,
}).
ThenSucceeds().
ThenReturnsAggregateID("order-123").
ThenReturnsVersion(2)
}
func TestCommandFailure(t *testing.T) {
bus := mink.NewCommandBus()
bdd.GivenCommand(t, bus, nil).
When(InvalidCommand{}).
ThenFails(mink.ErrValidationFailed)
}
Event Assertions
The testing/assertions package provides utilities for asserting event properties.
Basic Assertions
import "github.com/AshkanYarmoradi/go-mink/testing/assertions"
func TestOrderCreation(t *testing.T) {
order := NewOrder("order-123")
order.Create("customer-456")
order.AddItem("SKU-001", 2, 29.99)
events := order.UncommittedEvents()
// Assert event types
assertions.AssertEventTypes(t, events, "OrderCreated", "ItemAdded")
// Assert event count
assertions.AssertEventCount(t, events, 2)
// Assert first event data
assertions.AssertFirstEvent(t, events, OrderCreated{
OrderID: "order-123",
CustomerID: "customer-456",
})
// Assert last event
assertions.AssertLastEvent(t, events, ItemAdded{
OrderID: "order-123",
SKU: "SKU-001",
Quantity: 2,
Price: 29.99,
})
// Assert event at specific index
assertions.AssertEventAtIndex(t, events, 0, OrderCreated{
OrderID: "order-123",
CustomerID: "customer-456",
})
}
Contains Assertions
func TestContainsAssertions(t *testing.T) {
events := []interface{}{
OrderCreated{OrderID: "123"},
ItemAdded{SKU: "SKU-1"},
ItemAdded{SKU: "SKU-2"},
OrderShipped{OrderID: "123"},
}
// Assert contains specific event
assertions.AssertContainsEvent(t, events, ItemAdded{SKU: "SKU-1"})
// Assert contains event type
assertions.AssertContainsEventType(t, events, "OrderShipped")
// Assert no events (fails if not empty)
assertions.AssertNoEvents(t, []interface{}{})
}
Event Diffing
func TestEventDiffing(t *testing.T) {
expected := []interface{}{
OrderCreated{OrderID: "123", CustomerID: "cust-1"},
ItemAdded{SKU: "SKU-1", Quantity: 2},
}
actual := []interface{}{
OrderCreated{OrderID: "123", CustomerID: "cust-2"}, // Different customer
ItemAdded{SKU: "SKU-1", Quantity: 3}, // Different quantity
}
// Get differences
diffs := assertions.DiffEvents(expected, actual)
// Format for display
if len(diffs) > 0 {
t.Error(assertions.FormatDiffs(diffs))
}
// Or use assertion helper
assertions.AssertEventsEqual(t, expected, actual)
}
Event Matchers
func TestEventMatchers(t *testing.T) {
events := []interface{}{
OrderCreated{OrderID: "123"},
ItemAdded{SKU: "SKU-1"},
ItemAdded{SKU: "SKU-2"},
}
// Match by type
typeMatch := assertions.MatchEventType("ItemAdded")
assertions.AssertAnyMatch(t, events, typeMatch)
// Match specific event
eventMatch := assertions.MatchEvent(ItemAdded{SKU: "SKU-1"})
assertions.AssertAnyMatch(t, events, eventMatch)
// Assert all match
allItems := []interface{}{
ItemAdded{SKU: "SKU-1"},
ItemAdded{SKU: "SKU-2"},
}
assertions.AssertAllMatch(t, allItems, assertions.MatchEventType("ItemAdded"))
// Assert none match
assertions.AssertNoneMatch(t, events, assertions.MatchEventType("OrderCancelled"))
// Count matches
count := assertions.CountMatches(events, assertions.MatchEventType("ItemAdded"))
assert.Equal(t, 2, count)
// Filter events
filtered := assertions.FilterEvents(events, assertions.MatchEventType("ItemAdded"))
assert.Len(t, filtered, 2)
}
Projection Testing
The testing/projections package provides fixtures for testing projections.
Inline Projection Testing
import "github.com/AshkanYarmoradi/go-mink/testing/projections"
func TestOrderSummaryProjection(t *testing.T) {
projection := &OrderSummaryProjection{repo: mink.NewInMemoryRepository[OrderSummary](nil)}
projections.TestProjection[OrderSummary](t, projection).
GivenEvents(
mink.StoredEvent{
StreamID: "order-123",
Type: "OrderCreated",
Data: []byte(`{"order_id":"order-123","customer_id":"cust-456"}`),
},
mink.StoredEvent{
StreamID: "order-123",
Type: "ItemAdded",
Data: []byte(`{"sku":"SKU-1","quantity":2,"price":29.99}`),
},
).
ThenReadModel("order-123", OrderSummary{
ID: "order-123",
CustomerID: "cust-456",
ItemCount: 2,
TotalAmount: 59.98,
})
}
Testing with Domain Events
func TestProjectionWithDomainEvents(t *testing.T) {
projection := &OrderSummaryProjection{repo: mink.NewInMemoryRepository[OrderSummary](nil)}
projections.TestProjection[OrderSummary](t, projection).
GivenDomainEvents("order-123",
OrderCreated{OrderID: "order-123", CustomerID: "cust-456"},
ItemAdded{OrderID: "order-123", SKU: "SKU-1", Quantity: 2, Price: 29.99},
).
ThenReadModelExists("order-123")
}
Read Model Assertions
func TestReadModelAssertions(t *testing.T) {
projection := &OrderSummaryProjection{repo: mink.NewInMemoryRepository[OrderSummary](nil)}
fixture := projections.TestProjection[OrderSummary](t, projection).
GivenDomainEvents("order-123",
OrderCreated{OrderID: "order-123", CustomerID: "cust-456"},
)
// Assert existence
model := fixture.ThenReadModelExists("order-123")
// Assert non-existence
fixture.ThenReadModelNotExists("order-456")
// Assert count
fixture.ThenReadModelCount(1)
// Custom assertion
fixture.ThenReadModelMatches("order-123", func(t testing.TB, rm *OrderSummary) {
assert.Equal(t, "cust-456", rm.CustomerID)
assert.Equal(t, 0, rm.ItemCount)
})
}
Projection Engine Testing
func TestProjectionEngine(t *testing.T) {
fixture := projections.TestEngine(t).
RegisterInline(&OrderSummaryProjection{}).
Start()
defer fixture.Stop()
fixture.
AppendEvents("order-123",
OrderCreated{OrderID: "order-123", CustomerID: "cust-456"},
).
WaitForProjection("OrderSummary", 5*time.Second)
status, _ := fixture.Engine().GetStatus("OrderSummary")
assert.Equal(t, mink.ProjectionStateRunning, status.State)
}
Saga Testing
The testing/sagas package provides fixtures for testing sagas and process managers.
Basic Saga Testing
import "github.com/AshkanYarmoradi/go-mink/testing/sagas"
func TestOrderFulfillmentSaga(t *testing.T) {
saga := NewOrderFulfillmentSaga("saga-123")
sagas.TestSaga(t, saga).
GivenEvents(
mink.StoredEvent{
Type: "OrderCreated",
Data: []byte(`{"order_id":"order-123"}`),
},
).
ThenCommands(
RequestPaymentCommand{OrderID: "order-123"},
).
ThenNotCompleted()
}
func TestSagaCompletion(t *testing.T) {
saga := NewOrderFulfillmentSaga("saga-123")
sagas.TestSaga(t, saga).
GivenEvents(
mink.StoredEvent{Type: "OrderCreated", Data: []byte(`{"order_id":"order-123"}`)},
mink.StoredEvent{Type: "PaymentReceived", Data: []byte(`{"order_id":"order-123"}`)},
mink.StoredEvent{Type: "InventoryReserved", Data: []byte(`{"order_id":"order-123"}`)},
mink.StoredEvent{Type: "OrderShipped", Data: []byte(`{"order_id":"order-123"}`)},
).
ThenCompleted()
}
Saga Command Assertions
func TestSagaCommandAssertions(t *testing.T) {
saga := NewOrderFulfillmentSaga("saga-123")
sagas.TestSaga(t, saga).
GivenEvents(
mink.StoredEvent{Type: "OrderCreated", Data: []byte(`{"order_id":"order-123"}`)},
mink.StoredEvent{Type: "PaymentReceived", Data: []byte(`{"order_id":"order-123"}`)},
).
ThenCommandCount(2).
ThenFirstCommand(RequestPaymentCommand{OrderID: "order-123"}).
ThenLastCommand(ReserveInventoryCommand{OrderID: "order-123"}).
ThenContainsCommand(RequestPaymentCommand{OrderID: "order-123"})
}
Saga State Testing
func TestSagaState(t *testing.T) {
saga := NewOrderFulfillmentSaga("saga-123")
sagas.TestSaga(t, saga).
GivenEvents(
mink.StoredEvent{Type: "PaymentReceived", Data: []byte(`{}`)},
).
ThenState(SagaStateAwaitingInventory)
}
Compensation Testing
func TestSagaCompensation(t *testing.T) {
saga := NewOrderFulfillmentSaga("saga-123")
sagas.TestCompensation(t, saga).
GivenFailureAfter(
mink.StoredEvent{Type: "OrderCreated", Data: []byte(`{"order_id":"order-123"}`)},
mink.StoredEvent{Type: "PaymentReceived", Data: []byte(`{"order_id":"order-123"}`)},
mink.StoredEvent{Type: "InventoryFailed", Data: []byte(`{"order_id":"order-123"}`)},
).
ThenCompensates(
RefundPaymentCommand{OrderID: "order-123"},
CancelOrderCommand{OrderID: "order-123"},
)
}
Test Containers
The testing/containers package provides PostgreSQL test containers for integration tests.
Starting PostgreSQL
import "github.com/AshkanYarmoradi/go-mink/testing/containers"
func TestWithPostgres(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
container := containers.StartPostgres(t,
containers.WithPostgresImage("postgres:17"),
containers.WithPostgresDatabase("test_db"),
containers.WithPostgresUser("test_user"),
containers.WithPostgresPassword("test_pass"),
)
// Get connection
db := container.MustDB(context.Background())
defer db.Close()
// Use with mink adapter
adapter := postgres.NewAdapter(db)
store := mink.New(adapter)
// Run tests...
}
Test Isolation with Schemas
func TestWithIsolatedSchema(t *testing.T) {
container := containers.StartPostgres(t)
ctx := context.Background()
// Create isolated schema for this test
schema := container.CreateSchema(ctx, t, "test_order")
defer container.DropSchema(ctx, t, schema)
// Initialize mink tables in schema
container.SetupMinkSchema(ctx, t, schema)
// Get connection for schema
db := container.MustDBWithSchema(ctx, schema)
// Run tests with isolated data...
}
Integration Test Fixture
func TestFullIntegration(t *testing.T) {
it := containers.NewIntegrationTest(t)
// Store events
err := it.Store().Append(ctx, "order-123", []interface{}{
OrderCreated{OrderID: "order-123", CustomerID: "cust-456"},
})
require.NoError(t, err)
// Load and verify
events, err := it.Store().Load(ctx, "order-123", 0)
require.NoError(t, err)
assert.Len(t, events, 1)
}
Full Stack Testing
func TestFullStack(t *testing.T) {
fixture := containers.NewFullStackTest(t)
// Access components
store := fixture.Store()
bus := fixture.CommandBus()
engine := fixture.ProjectionEngine()
// Run full integration test...
}
Mock Adapters
The testing/testutil package provides mock implementations for testing.
Mock Event Store Adapter
import "github.com/AshkanYarmoradi/go-mink/testing/testutil"
func TestWithMockAdapter(t *testing.T) {
adapter := &testutil.MockAdapter{}
store := mink.New(adapter)
// Configure mock behavior
adapter.AppendErr = mink.ErrConcurrencyConflict
// Test error handling
err := store.Append(ctx, "order-123", events)
assert.ErrorIs(t, err, mink.ErrConcurrencyConflict)
}
Mock Projection
func TestWithMockProjection(t *testing.T) {
projection := testutil.NewMockProjection("TestProjection",
testutil.WithHandledEvents("OrderCreated", "ItemAdded"),
)
// Apply events
err := projection.Apply(ctx, storedEvent)
require.NoError(t, err)
// Check applied events
assert.Len(t, projection.AppliedEvents, 1)
}
Running Tests
Unit Tests Only
# No infrastructure required
go test -short ./...
All Tests with Infrastructure
# Start PostgreSQL
docker-compose -f docker-compose.test.yml up -d
# Run all tests
go test ./...
# Or use make
make test
Tests with Coverage
make test-coverage
# View HTML report
go tool cover -html=coverage.out
Environment Variables
The test containers respect these environment variables:
| Variable | Default | Description |
|---|---|---|
POSTGRES_IMAGE |
postgres:17 |
Docker image |
POSTGRES_DB |
mink_test |
Database name |
POSTGRES_USER |
postgres |
Username |
POSTGRES_PASSWORD |
postgres |
Password |
POSTGRES_PORT |
5432 |
Host port |
Best Practices
1. Test Aggregate Logic First
// Good: Test aggregate behavior in isolation
func TestOrderLogic(t *testing.T) {
bdd.Given(t, NewOrder("123")).
When(func() error { return order.AddItem(...) }).
Then(...)
}
2. Use BDD for Readability
// Good: Clear Given-When-Then structure
bdd.Given(t, aggregate, previousEvents...).
When(commandFunc).
Then(expectedEvents...)
// Avoid: Imperative test code that's hard to read
order.ApplyEvent(event1)
order.ApplyEvent(event2)
err := order.DoSomething()
assert.NoError(t, err)
events := order.UncommittedEvents()
assert.Len(t, events, 1)
3. Isolate Integration Tests
// Good: Use schema isolation
schema := container.CreateSchema(ctx, t, "test_"+t.Name())
defer container.DropSchema(ctx, t, schema)
4. Skip Slow Tests in Short Mode
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
// ...
}
5. Use Table-Driven Tests
func TestOrderValidation(t *testing.T) {
tests := []struct {
name string
command AddItemCommand
wantErr error
}{
{"valid", AddItemCommand{SKU: "SKU-1", Qty: 1}, nil},
{"zero quantity", AddItemCommand{SKU: "SKU-1", Qty: 0}, ErrInvalidQuantity},
{"empty SKU", AddItemCommand{SKU: "", Qty: 1}, ErrInvalidSKU},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.command.Validate()
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
Next: Security