ADR-008: BDD Testing Strategy
| Status | Date | Deciders |
|---|---|---|
| Accepted | 2024-04-01 | Core Team |
Context
Event-sourced systems require comprehensive testing at multiple levels:
- Aggregate Logic: Domain rules and state transitions
- Projections: Read model building from events
- Integration: Full command→event→projection flow
- 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
- Expressive Tests: Given-When-Then reads like requirements
- Fast Feedback: Unit tests run in milliseconds
- Comprehensive: Cover domain, projections, and integration
- Debugging: Diff output helps identify issues
- Documentation: Tests document expected behavior
Negative
- Learning Curve: New DSL to learn
- Maintenance: Testing utilities need maintenance
- Custom Matchers: Sometimes need domain-specific matchers
Neutral
- Test Organization: Need conventions for test structure
- 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