
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. TheStartStream()overloads that generate their own stream id return thatGuidso 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>()andDocumentShouldNotExist<T>()cover the common cases, andAssertAgainstProjectedData()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
ProjectionScenarioExceptionthat lists each step and what went wrong. Assertion failures inside the aggregate are typed asProjectionScenarioAssertionExceptionso 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.