Testing
Event sourcing has a natural testing vocabulary that no other architecture provides. Because the entire system is built on events as facts, tests read like specifications:
Given these events happened in the past, When this command is issued, Then these new events should be produced — or the command should be rejected.
This isn't a testing framework bolted onto the side. It's the native language of event-sourced systems.
Setup
dotnet add package Nagare.Testingusing Nagare.Testing;
var harness = new AggregateTestHarness<BookAggregate, BookCommand, BookEvent, BookState>();The harness runs aggregates entirely in-memory — no database, no DI container, no infrastructure. Just your domain logic, isolated and fast.
Given-When-Then
First command on a new aggregate
An empty history means a brand-new aggregate:
harness
.Given() // no prior history
.When(new AddBook("Dune", "Frank Herbert", "978-0441172719"))
.ThenAccepted()
.ThenExpect<BookAdded>(e => e.Title == "Dune");Command with prior history
Events establish the starting state:
harness
.Given(new BookAdded("Dune", "Frank Herbert", "978-0441172719"))
.When(new BorrowBook("user-42"))
.ThenAccepted()
.ThenExpect<BookBorrowed>(e => e.BorrowerId == "user-42");Command rejected
Business rules enforce constraints:
harness
.Given() // book doesn't exist
.When(new BorrowBook("user-42"))
.ThenRejected("Book does not exist");Typed rejection
When you need to assert a specific exception type:
harness
.Given(new BookAdded(...), new BookBorrowed(...))
.When(new BorrowBook("user-99"))
.ThenExpectRejection<BookAlreadyBorrowedException>();Idempotent command
Accepting without producing events — the aggregate recognizes the command is already satisfied:
harness
.Given(new BookAdded(...), new BookLost(...))
.When(new ReportLost())
.ThenAccepted()
.ThenExpectCount(0); // no new eventsExact event sequence
When order matters:
harness
.Given()
.When(new CheckoutCart("credit-card"))
.ThenExpectSequence<CartCheckedOut, PaymentInitiated>();Why this works so well
Event-sourced aggregates are pure functions at the boundary: given a history (events) and an input (command), they produce a deterministic output (new events or a rejection). No database calls. No randomness. No time dependencies (unless you inject a clock).
This means:
- Tests are fast — milliseconds, not seconds
- Tests are deterministic — no flaky failures from infrastructure
- Tests are readable — they describe business rules in domain language
- Tests are complete — every path through a command handler can be covered
Compare this with testing a CRUD service: you need a database, a transaction, mocked dependencies, setup and teardown. With event sourcing, you need a list of events and a command.
Naming tests
Test names should read as specifications — sentences that describe what the system does. Use underscores between words so they render clearly in test runner output.
[Fact]
public void Cannot_borrow_book_that_is_already_borrowed()
{
_harness
.Given(new BookAdded("Dune", "Herbert", "978-0441172719"),
new BookBorrowed("user-1", DateTimeOffset.UtcNow))
.When(new BorrowBook("user-2"))
.ThenRejected("already borrowed");
}
[Fact]
public void Reporting_a_lost_book_twice_is_idempotent() { ... }
[Fact]
public void Placing_an_order_emits_OrderPlaced_with_correct_total() { ... }
[Fact]
public void Cannot_ship_a_cancelled_order() { ... }When your test suite reads like a list of business rules, it becomes living documentation. New team members understand the domain by reading the tests.
What to test
Every command path
Each command handler has three possible outcomes: accepted with events, accepted without events (idempotent), or rejected. Test all three.
Empty history
The first command on a new aggregate is special — there's no prior state. This is a common source of bugs.
Boundary conditions
Stock == 0, exact quantity limits, date boundaries. The edges are where bugs live.
Idempotency
Send the same command twice. The first should produce events, the second should accept without events (or produce the same events, depending on your semantics).
Every rejection path
Each rejection reason should have its own test. This documents every constraint the aggregate enforces and catches regressions when business rules change.
Integration tests
For testing the full stack — event store, serialization, projection — against a real database:
// SQLite — fast, no container needed
public class SqliteBookStoreTests : EventStoreTests<BookEvent>
{
public SqliteBookStoreTests() : base(new SqliteFixture()) { }
}
// SQL Server — Testcontainers
[Collection("MsSql")]
public class MsSqlBookStoreTests : EventStoreTests<BookEvent>
{
public MsSqlBookStoreTests(MsSqlFixture fixture) : base(fixture) { }
}The Testing package provides base test classes that validate the event store contract against any database backend. These verify that events round-trip correctly through serialization, that optimistic concurrency is enforced, and that projections rebuild from checkpoints.
Start with SQLite
SQLite tests run in milliseconds and require no infrastructure. Use them for fast feedback during development. Reserve SQL Server and PostgreSQL tests for CI and pre-release verification.
Eventually — polling for projection consistency
Projections are eventually consistent. After writing through an aggregate, the subscription processes events asynchronously. Tests that write then read need to wait for the projection to catch up.
Don't use Task.Delay. It's fragile — too short and you get flaky tests, too long and your suite is slow. Use Eventually.Get which polls until the condition is met or a timeout expires.
using Nagare.Testing;
// Write through aggregate
var agg = await repo.Load(aggregateId);
await agg.Ask(new Increment(42));
// Poll until the projection catches up
var readModel = await Eventually.Get(
() => projection.Find(aggregateId)!,
r => r is not null && r.Value == 42);Wait for a specific state after a command:
await agg.Ask(new Cancel("reason"));
var result = await Eventually.Get(
() => projection.Find(aggregateId)!,
r => r is not null && r.Status == "Cancelled");Eventually.UntilNotNull is a convenience for waiting until a projection exists:
var readModel = await Eventually.UntilNotNull(
() => projection.Find(aggregateId));Parameters:
timeout: Maximum time to wait (default: 5 seconds)interval: Time between polls (default: 100ms)
Behavior:
- Returns the last result even if the condition was never met — the test assertion produces a useful failure message instead of a timeout exception
- Catches transient exceptions during polling (e.g., projection table not yet created by the subscription)
Querying projections
Document projections expose a Find(AggregateId) method for reading projected state:
var readModel = await projection.Find(new AggregateId("counter-1"));
// Returns null if not foundTest structure
A typical test class mirrors the aggregate it tests:
public class BookAggregateTests
{
private readonly AggregateTestHarness<BookAggregate, BookCommand, BookEvent, BookState>
_harness = new();
// --- AddBook ---
[Fact]
public void Can_add_a_new_book() { ... }
[Fact]
public void Cannot_add_a_book_that_already_exists() { ... }
// --- BorrowBook ---
[Fact]
public void Can_borrow_an_available_book() { ... }
[Fact]
public void Cannot_borrow_book_that_does_not_exist() { ... }
[Fact]
public void Cannot_borrow_book_that_is_already_borrowed() { ... }
[Fact]
public void Cannot_borrow_book_that_is_lost() { ... }
// --- ReturnBook ---
[Fact]
public void Can_return_a_borrowed_book() { ... }
[Fact]
public void Cannot_return_book_that_is_not_borrowed() { ... }
// --- ReportLost ---
[Fact]
public void Can_report_a_book_as_lost() { ... }
[Fact]
public void Reporting_a_lost_book_twice_is_idempotent() { ... }
[Fact]
public void Cannot_report_lost_for_nonexistent_book() { ... }
}Group tests by command. Within each group, test the happy path first, then rejections, then edge cases. The test list itself becomes a specification of the aggregate's behavior.
Next: Middleware