Skip to main content

Aggregate Testing

The AggregateTestFixture<T> class provides a fluent Given-When-Then API for testing event-sourced aggregates. Tests are fast (in-memory), isolated, and framework-agnostic.

Before You Start

Installation

dotnet add package Excalibur.Testing

Given-When-Then Pattern

Event-sourced aggregates are tested using the Given-When-Then pattern:

PhasePurposeMethod
GivenSet up historical events (prior state).Given(events)
WhenExecute a command on the aggregate.When(action)
ThenAssert events raised or exceptions thrown.Then().ShouldRaise<T>()
new AggregateTestFixture<OrderAggregate>()
.Given(/* historical events */)
.When(/* command to execute */)
.Then()
.ShouldRaise<ExpectedEvent>();

Basic Examples

Testing Event Generation

[Fact]
public void Create_order_raises_OrderCreated()
{
new AggregateTestFixture<Order>()
.Given() // No prior events
.When(order => order.Create("customer-123"))
.Then()
.ShouldRaise<OrderCreated>();
}

Testing with Prior State

[Fact]
public void Add_item_to_existing_order()
{
new AggregateTestFixture<Order>()
.Given(new OrderCreated { OrderId = "123", CustomerId = "C1" })
.When(order => order.AddItem("SKU-001", quantity: 2, price: 29.99m))
.Then()
.ShouldRaise<OrderItemAdded>(e =>
e.Sku == "SKU-001" &&
e.Quantity == 2);
}

Testing State After Events

[Fact]
public void Order_total_calculated_correctly()
{
new AggregateTestFixture<Order>()
.Given(
new OrderCreated { OrderId = "123", CustomerId = "C1" },
new OrderItemAdded { Sku = "A", Quantity = 2, UnitPrice = 10.00m },
new OrderItemAdded { Sku = "B", Quantity = 1, UnitPrice = 25.00m })
.When(order => { /* no action - just verify state */ })
.Then()
.StateShould(order => order.TotalAmount == 45.00m);
}

Assertion Methods

ShouldRaise<TEvent>()

Asserts that an event of the specified type was raised:

.Then().ShouldRaise<OrderShipped>();

ShouldRaise<TEvent>(predicate)

Asserts an event matching a predicate was raised:

.Then().ShouldRaise<OrderShipped>(e =>
e.OrderId == "123" &&
e.ShippedAt.Date == DateTime.Today);

ShouldRaiseNoEvents()

Asserts no events were raised (useful for no-op scenarios):

.Then().ShouldRaiseNoEvents();

StateShould(predicate)

Asserts the aggregate state matches a condition:

.Then().StateShould(order => order.Status == OrderStatus.Shipped);

AssertAggregate(action)

Provides direct access for custom assertions:

.Then().AssertAggregate(order =>
{
Assert.Equal(3, order.Items.Count);
Assert.Equal("customer-123", order.CustomerId);
Assert.True(order.TotalAmount > 0);
});

ShouldThrow<TException>()

Asserts a specific exception was thrown:

.When(order => order.Ship())
.ShouldThrow<InvalidOperationException>();

ShouldThrow<TException>(messageContains)

Asserts an exception with a specific message was thrown:

.ShouldThrow<InvalidOperationException>("already shipped");

ShouldNotThrow()

Asserts no exception was thrown:

.Then().ShouldNotThrow();

Testing Exception Scenarios

Domain Rule Violations

[Fact]
public void Cannot_add_item_to_submitted_order()
{
new AggregateTestFixture<Order>()
.Given(
new OrderCreated { OrderId = "123", CustomerId = "C1" },
new OrderSubmitted { OrderId = "123" })
.When(order => order.AddItem("SKU-001", 1, 10.00m))
.ShouldThrow<InvalidOperationException>("submitted");
}

Business Invariant Enforcement

[Fact]
public void Order_item_quantity_must_be_positive()
{
new AggregateTestFixture<Order>()
.Given(new OrderCreated { OrderId = "123", CustomerId = "C1" })
.When(order => order.AddItem("SKU-001", quantity: 0, price: 10.00m))
.ShouldThrow<ArgumentOutOfRangeException>();
}

Method Chaining

Assertions can be chained to verify multiple conditions:

new AggregateTestFixture<Order>()
.Given(new OrderCreated { OrderId = "123", CustomerId = "C1" })
.When(order => order.Submit())
.Then()
.ShouldRaise<OrderSubmitted>()
.ShouldRaise<PaymentRequested>()
.StateShould(order => order.Status == OrderStatus.PendingPayment)
.StateShould(order => order.SubmittedAt.HasValue);

Testing Multiple Events

When a command raises multiple events:

[Fact]
public void Complete_order_raises_multiple_events()
{
new AggregateTestFixture<Order>()
.Given(
new OrderCreated { OrderId = "123", CustomerId = "C1" },
new PaymentReceived { OrderId = "123", Amount = 100.00m })
.When(order => order.Complete())
.Then()
.ShouldRaise<OrderCompleted>()
.ShouldRaise<InventoryReserved>()
.ShouldRaise<CustomerNotified>();
}

Parameterized Tests

Use xUnit's [Theory] for data-driven tests:

[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public void Quantity_must_be_positive(int invalidQuantity)
{
new AggregateTestFixture<Order>()
.Given(new OrderCreated { OrderId = "123", CustomerId = "C1" })
.When(order => order.AddItem("SKU", invalidQuantity, 10.00m))
.ShouldThrow<ArgumentOutOfRangeException>();
}

[Theory]
[InlineData(1, 10.00, 10.00)]
[InlineData(2, 10.00, 20.00)]
[InlineData(3, 15.50, 46.50)]
public void Item_total_calculated_correctly(int qty, decimal price, decimal expected)
{
new AggregateTestFixture<Order>()
.Given(new OrderCreated { OrderId = "123", CustomerId = "C1" })
.When(order => order.AddItem("SKU", qty, price))
.Then()
.ShouldRaise<OrderItemAdded>(e => e.LineTotal == expected);
}

Testing Idempotency

Some operations should be idempotent (safe to repeat):

[Fact]
public void Cancel_already_cancelled_order_is_idempotent()
{
new AggregateTestFixture<Order>()
.Given(
new OrderCreated { OrderId = "123", CustomerId = "C1" },
new OrderCancelled { OrderId = "123", Reason = "First cancellation" })
.When(order => order.Cancel("Second cancellation attempt"))
.Then()
.ShouldRaiseNoEvents(); // No duplicate event
}

Organizing Test Classes

Structure tests by aggregate and scenario:

public class OrderTests
{
public class Creation
{
[Fact]
public void Creates_with_customer_id() { }

[Fact]
public void Requires_valid_customer_id() { }
}

public class AddingItems
{
[Fact]
public void Adds_item_to_order() { }

[Fact]
public void Cannot_add_to_submitted_order() { }
}

public class Submission
{
[Fact]
public void Submits_order_with_items() { }

[Fact]
public void Cannot_submit_empty_order() { }
}
}

Best Practices

PracticeReason
One behavior per testClear failure messages
Descriptive test namesSelf-documenting tests
Minimal Given eventsOnly what's needed for the test
Test edge casesEmpty collections, boundaries, nulls
Don't test implementationTest behavior, not how it's implemented
Avoid test interdependenceEach test should be isolated

Troubleshooting

"Expected event X was not raised"

  • Verify the command actually calls RaiseEvent()
  • Check the event type matches exactly (including namespace)
  • Ensure Given events don't put aggregate in wrong state

"Aggregate state did not match"

  • Debug with .AssertAggregate() to inspect actual state
  • Verify Apply methods are correctly implemented
  • Check that historical events are complete

Tests pass individually but fail together

  • Ensure aggregates don't share static state
  • Each test creates a new AggregateTestFixture instance

See Also