Skip to main content

ADR-008: BDD Testing Strategy

StatusDateDeciders
Accepted2024-04-01Core Team

Context

Event-sourced systems require comprehensive testing at multiple levels:

  1. Aggregate Logic: Domain rules and state transitions
  2. Projections: Read model building from events
  3. Integration: Full command-event-projection flow
  4. Infrastructure: Database adapters, serialization

Traditional testing approaches have limitations:

  • State-based tests: Don't verify events produced
  • Mock-heavy tests: Don't catch integration issues
  • Integration tests only: Too slow for TDD

We need a testing strategy that is:

  • Expressive for domain logic
  • Fast for development
  • Comprehensive for production confidence

Decision

We will implement a BDD-style testing framework with specialized utilities for event-sourced systems.

Core Testing Package: testing/bdd

Given-When-Then pattern for aggregate tests:

func TestOrder_Creation(t *testing.T) {
order := NewOrder("order-123")

bdd.Given(t, order).
When(func() error {
return order.Create("customer-456", items)
}).
Then(OrderCreated{
OrderID: "order-123",
CustomerID: "customer-456",
Items: items,
})
}

func TestOrder_CannotShipUnpaid(t *testing.T) {
order := NewOrder("order-123")

bdd.Given(t, order,
OrderCreated{OrderID: "order-123"},
).
When(func() error {
return order.Ship("TRACK-123", "FedEx")
}).
ThenError(ErrOrderNotPaid)
}

Implementation

package bdd

type TestFixture struct {
t *testing.T
aggregate Aggregate
}

func Given(t *testing.T, agg Aggregate, events ...interface{}) *TestFixture {
t.Helper()

// Apply given events
for _, event := range events {
if err := agg.ApplyEvent(event); err != nil {
t.Fatalf("Failed to apply given event: %v", err)
}
}
agg.ClearUncommittedEvents()

return &TestFixture{t: t, aggregate: agg}
}

func (f *TestFixture) When(action func() error) *WhenResult {
f.t.Helper()
err := action()
return &WhenResult{
t: f.t,
aggregate: f.aggregate,
err: err,
}
}

type WhenResult struct {
t *testing.T
aggregate Aggregate
err error
}

func (r *WhenResult) Then(expectedEvents ...interface{}) {
r.t.Helper()

if r.err != nil {
r.t.Fatalf("Action failed unexpectedly: %v", r.err)
}

actualEvents := r.aggregate.UncommittedEvents()

if len(actualEvents) != len(expectedEvents) {
r.t.Fatalf("Expected %d events, got %d",
len(expectedEvents), len(actualEvents))
}

for i, expected := range expectedEvents {
if !eventsEqual(expected, actualEvents[i]) {
r.t.Errorf("Event %d mismatch:\nExpected: %+v\nActual: %+v",
i, expected, actualEvents[i])
}
}
}

func (r *WhenResult) ThenError(expectedErr error) {
r.t.Helper()

if r.err == nil {
r.t.Fatal("Expected error but action succeeded")
}

if !errors.Is(r.err, expectedErr) {
r.t.Fatalf("Expected error %v, got %v", expectedErr, r.err)
}
}

func (r *WhenResult) ThenNoError() {
r.t.Helper()

if r.err != nil {
r.t.Fatalf("Expected no error, got %v", r.err)
}
}

func (r *WhenResult) ThenMatches(matcher func(events []interface{}) bool) {
r.t.Helper()

if r.err != nil {
r.t.Fatalf("Action failed unexpectedly: %v", r.err)
}

events := r.aggregate.UncommittedEvents()
if !matcher(events) {
r.t.Fatalf("Events did not match custom matcher: %+v", events)
}
}

Event Assertions: testing/assertions

package assertions

// Assert event types in order
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, typ := range types {
actualType := reflect.TypeOf(events[i]).Name()
if actualType != typ {
t.Errorf("Event %d: expected type %s, got %s", i, typ, actualType)
}
}
}

// Deep field comparison
func AssertEvent(t *testing.T, actual interface{}, matcher EventMatcher) {
t.Helper()

actualType := reflect.TypeOf(actual).Name()
if actualType != matcher.Type {
t.Errorf("Expected type %s, got %s", matcher.Type, actualType)
return
}

for field, expected := range matcher.Fields {
actualValue := getField(actual, field)
if !reflect.DeepEqual(actualValue, expected) {
t.Errorf("Field %s: expected %v, got %v", field, expected, actualValue)
}
}
}

// Generate diff for debugging
func DiffEvents(expected, actual interface{}) string {
expectedJSON, _ := json.MarshalIndent(expected, "", " ")
actualJSON, _ := json.MarshalIndent(actual, "", " ")

return fmt.Sprintf("Expected:\n%s\n\nActual:\n%s", expectedJSON, actualJSON)
}

Projection Testing: testing/projections

package projections

func NewTestEvent(eventType string, data interface{}) mink.StoredEvent {
jsonData, _ := json.Marshal(data)
return mink.StoredEvent{
ID: uuid.New().String(),
StreamID: "test-stream",
Type: eventType,
Data: jsonData,
Version: 1,
Timestamp: time.Now(),
}
}

type ProjectionTestFixture struct {
projection Projection
events []mink.StoredEvent
}

func GivenProjection(p Projection) *ProjectionTestFixture {
return &ProjectionTestFixture{projection: p}
}

func (f *ProjectionTestFixture) WithEvents(events ...mink.StoredEvent) *ProjectionTestFixture {
f.events = append(f.events, events...)
return f
}

func (f *ProjectionTestFixture) ExpectState(t *testing.T, check func()) {
ctx := context.Background()

for _, event := range f.events {
if err := f.projection.Apply(ctx, event); err != nil {
t.Fatalf("Failed to apply event: %v", err)
}
}

check()
}

Test Containers: testing/containers

package containers

type PostgresContainer struct {
container testcontainers.Container
host string
port string
}

func NewPostgresContainer(ctx context.Context) (*PostgresContainer, error) {
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "mink_test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, err
}

host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")

return &PostgresContainer{
container: container,
host: host,
port: port.Port(),
}, nil
}

func (c *PostgresContainer) ConnectionString() string {
return fmt.Sprintf("postgres://postgres:test@%s:%s/mink_test?sslmode=disable",
c.host, c.port)
}

func (c *PostgresContainer) Terminate(ctx context.Context) error {
return c.container.Terminate(ctx)
}

Consequences

Positive

  1. Expressive Tests: Given-When-Then reads like requirements
  2. Fast Feedback: Unit tests run in milliseconds
  3. Comprehensive: Cover domain, projections, and integration
  4. Debugging: Diff output helps identify issues
  5. Documentation: Tests document expected behavior

Negative

  1. Learning Curve: New DSL to learn
  2. Maintenance: Testing utilities need maintenance
  3. Custom Matchers: Sometimes need domain-specific matchers

Neutral

  1. Test Organization: Need conventions for test structure
  2. Coverage: Should aim for high coverage of domain logic

Test Organization

myapp/
├── internal/
│ └── domain/
│ └── order/
│ ├── order.go
│ ├── order_test.go # BDD aggregate tests
│ ├── events.go
│ └── events_test.go # Event serialization tests
├── internal/
│ └── projections/
│ ├── order_summary.go
│ └── order_summary_test.go # Projection tests
└── internal/
└── integration/
└── integration_test.go # Full flow tests

References