Declarative Testing Helper for Marten or Polecat Projections

We’ve had an undocumented until now API in Marten for years called EventProjectionScenario for declarative testing of Marten projections — somewhat based on the Scenario usage in our Alba library for ASP.Net Core testing. As part of some cleanup this week, I finally added some documentation and lifted that to where Polecat (Event Sourcing with SQL Server) can use it as well.

I’m more curious than anything to get some feedback here if anyone things this would be useful. After CritterWatch 1.0 lands, my attention is going to turn to the Critter Stack’s story for “Spec Driven Development,” and maybe this feature will be part of that.

Scripted Scenarios with EventProjectionScenario

For a more declarative way to test a projection end to end, Marten has a built-in scenario runner on IDocumentStore.Advanced that scripts a sequence of event appends and document assertions, then executes the whole sequence for you:

[Fact]
public async Task happy_path_test_with_inline_projection()
{
// This is from a shared testing context class we use
// to test Marten itself. This is just a short cut to say
// if I have a DocumentStore configured like this...
StoreOptions(opts =>
{
opts.Projections.Add(new UserProjection(), ProjectionLifecycle.Inline);
});
await theStore.Advanced.EventProjectionScenario(scenario =>
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var id3 = Guid.NewGuid();
scenario.Append(Guid.NewGuid(), new CreateUser {UserId = id1, UserName = "Kareem"});
scenario.Append(Guid.NewGuid(), new CreateUser {UserId = id2, UserName = "Magic"});
scenario.Append(Guid.NewGuid(), new CreateUser {UserId = id3, UserName = "James"});
scenario.DocumentShouldExist<User>(id1);
scenario.DocumentShouldExist<User>(id2);
// In this usage you can make assertions against the
// expected state of the projected document
scenario.DocumentShouldExist<User>(id3, user => user.UserName.ShouldBe("James"));
scenario.Append(Guid.NewGuid(), new DeleteUser {UserId = id2});
scenario.DocumentShouldExist<User>(id1);
scenario.DocumentShouldNotExist<User>(id2);
scenario.DocumentShouldExist<User>(id3);
}, TestContext.Current.CancellationToken);
}

The scenario works with any projection lifecycle. If the store has any asynchronous projections registered, the scenario quietly spins up a projection daemon, waits for it to catch up after each batch of appended events, and shuts it down afterward — your test code looks identical either way.

A few things to know about how a scenario executes:

  • The Append() / StartStream() / AppendEvents() calls queue work — nothing touches the database until the scenario executes. The StartStream() overloads that generate their own stream id return that Guid so you can capture it for later assertions.
  • Consecutive appends are batched into a single commit; the pending work is saved whenever the next step is an assertion, and once more at the end of the scenario.
  • Assertion steps run against a query session after the projected data is up to date. DocumentShouldExist<T>() and DocumentShouldNotExist<T>() cover the common cases, and AssertAgainstProjectedData() is the general purpose hook for anything else.
  • If an action step fails, the scenario stops immediately — the remaining steps would be running against a state nobody intended. Failed assertions accumulate instead, and everything is reported at the end in a single ProjectionScenarioException that lists each step and what went wrong. Assertion failures inside the aggregate are typed as ProjectionScenarioAssertionException so tooling can tell them apart from infrastructure failures.

WARNING

By default the scenario deletes all event data plus the storage for every registered projection before it runs, so each scenario starts from a clean slate. Only use this feature against a test database! To run a scenario on top of existing data instead, set scenario.DeleteExistingData = false.

The scenario object exposes a few knobs:

await theStore.Advanced.EventProjectionScenario(scenario =>
{
// Keep any existing event/projection data (the default is to wipe it)
scenario.DeleteExistingData = false;
// Apply the whole scenario to one tenant when using multi-tenancy
scenario.TenantId = "tenant1";
// Maximum time to wait for async projections to catch up
// after each batch of events. The default is 30 seconds
scenario.Timeout = 5.Seconds();
// ... queue up appends and assertions
});

We’ll also have the “Projection Stepper” feature in CritterWatch that will allow you to step through a series of events to see how a projection creates and modifies its view event by event. That functionality is part of the CritterWatch user interface, but also exposed via an MCP endpoint on CritterWatch for easy access for AI agents building and troubleshooting Critter Stack applications using Event Sourcing.

Leave a comment