Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -613,15 +613,28 @@
all_exceptions_should_derive_from_MartenException keeps Marten on its own hierarchy until
Marten 10, and C# has single inheritance. The lift is inert here, which is the intent.
Also jasperfx#790, a daemon race in the shared compliance suites. -->
<PackageVersion Include="JasperFx" Version="2.72.0" />
<PackageVersion Include="JasperFx.Events" Version="2.72.0" />
<!-- JasperFx 2.73.2 (from 2.72.0, spanning 2.73.0 and 2.73.1). Marten takes this bump for one
thing it consumes directly:
(a) jasperfx#858/#862 - JasperFx.Events.StubEventStream&lt;T&gt;, a store-agnostic unit-test
stand-in for IEventStream&lt;T&gt;. marten#5455/#5458 document it, and Marten's own
Marten.Events.StubEventStream&lt;T&gt; is now a thin subclass of it so there is one
implementation rather than two types of the same name behaving slightly differently.
⚠️ Do NOT pin 2.73.0: jasperfx#859/#863 dropped EventModelClaim's two-argument constructor
there and restored it in 2.73.1. Nothing in Marten binds that type today, but the floor is
2.73.1 for anyone who does.
Also rides along, inert here: jasperfx#859/#861 (an event-model source disagreement names
the source, not just the rung), jasperfx#853/#860 (ModelCollapse carries its service and
model names as structured data) and wolverine#4486 (a `codegen test` fix for AotRoots
naming a sibling generated type). -->
<PackageVersion Include="JasperFx" Version="2.73.2" />
<PackageVersion Include="JasperFx.Events" Version="2.73.2" />
<!--
The shared cross-store event sourcing compliance suites (#5116). Source-only package: the
suites compile inside EventSourcingTests so JasperFx's aggregate source generator can bind
Marten's own session types. Marten.Testing needs it too because MartenComplianceFixture,
which closes the seam, lives beside the rest of the harness.
-->
<PackageVersion Include="JasperFx.Events.ComplianceTests" Version="2.72.0" />
<PackageVersion Include="JasperFx.Events.ComplianceTests" Version="2.73.2" />
<!--
No longer ahead of the rest of the JasperFx line. As of the 2.59.0 bump (marten#5305) the
whole family moves together, and this note is kept for the history below. This package is
Expand All @@ -640,11 +653,11 @@
which does not care how the instance was constructed. The generator is otherwise byte-identical
from 2.38.0 through 2.42.0, so this is an isolated swap of that one fix.
-->
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.72.0">
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.73.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.72.0" />
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.73.2" />
<PackageVersion Include="Jil" Version="3.0.0-alpha2" />
<PackageVersion Include="Lamar" Version="7.1.1" />
<PackageVersion Include="Lamar.Microsoft.DependencyInjection" Version="15.0.0" />
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ const config: UserConfig<DefaultTheme.Config> = {
collapsed: true,
items: [
{ text: 'Integration Testing', link: '/testing/integration' },
{ text: 'Unit Testing Event Handlers', link: '/testing/unit-testing-event-handlers' },
]
},
{
Expand Down
5 changes: 5 additions & 0 deletions docs/scenarios/command_handler_workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Wolverine [Aggregate Handler Workflow](https://wolverinefx.net/guide/durability/
the `FetchForWriting()` API under the covers that is introduced in this topic.
:::

::: tip
A command handler that takes an `IEventStream<T>` can be unit tested against a `StubEventStream<T>`
with no database at all — see [Unit Testing Event Handlers](/testing/unit-testing-event-handlers).
:::

So you're using Marten's event sourcing functionality within some kind architecture (CQRS maybe?) where your business logic needs to emit events modeling
business state changes based on external inputs (commands). These commands are most likely working on a single event stream at one time. Your business logic
will probably need to evaluate the incoming command against the current state of the event stream to either decide what events should be created, or to reject
Expand Down
5 changes: 5 additions & 0 deletions docs/testing/integration.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Integration testing

::: tip
For the other half of the suite — testing what a command handler *decides*, with no database at
all — see [Unit Testing Event Handlers](/testing/unit-testing-event-handlers).
:::

This documentation aims to guide you through the process of performing integration tests with Marten. We will go through setting up the host using [Alba](https://jasperfx.github.io/alba/), integrating with [Wolverine](https://wolverinefx.net), and testing event projections. The examples provided will leverage [Alba](https://jasperfx.github.io/alba/) and [xUnit](https://xunit.net/) for testing, but integration testing should be perfectly possible using Microsoft's [WebapplicationFactory](https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests) and other testing frameworks like NUnit.

## Setting Up The Database Using Docker
Expand Down
267 changes: 267 additions & 0 deletions docs/testing/unit-testing-event-handlers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
# Unit testing event-sourced handlers

[Integration testing](/testing/integration) covers the half of your test suite that needs a real
database — persistence, optimistic concurrency, and projection correctness. This page is the other
half: testing the *decision* a command handler makes, with no Postgres involved at all.

A handler that takes an `IEventStream<T>` — through Wolverine's
[aggregate handler workflow](https://wolverinefx.net/guide/durability/marten/event-sourcing.html),
or as the body you hand to `WriteToAggregate()` — is a function from *current aggregate state plus a
command* to *the events that should be appended*. Both halves of that are just objects, so the test
needs nothing more than a stand-in for the stream.

`JasperFx.Events.StubEventStream<T>` is that stand-in. It records appended events in a list instead
of persisting them, and it reads identically across Marten, Polecat and Fisher.

## The handler under test

<!-- snippet: sample_stub_event_stream_subject -->
<a id='snippet-sample_stub_event_stream_subject'></a>
```cs
public record Booking(Guid Id, int Seats, bool IsCancelled);

public record ReserveSeats(Guid BookingId, int Seats);

public record TransferSeats(Guid FromBookingId, Guid ToBookingId, int Seats);

public record BookingStarted(int Seats);

public record SeatsReserved(int Seats);

public record SeatsReleased(int Seats);

public record BookingRejected(string Reason);

public static class BookingHandler
{
// Wolverine's aggregate handler workflow hands the handler a live stream handle:
// public static void Handle(ReserveSeats command, [WriteAggregate] IEventStream<Booking> stream)
// and inside Marten itself this is the body you would pass to WriteToAggregate().
public static void Handle(ReserveSeats command, IEventStream<Booking> stream)
{
var booking = stream.Aggregate;

// A null aggregate is a stream that does not exist yet
if (booking is null)
{
stream.AppendOne(new BookingStarted(command.Seats));
return;
}

if (booking.IsCancelled)
{
stream.AppendOne(new BookingRejected("Booking has been cancelled"));
return;
}

// Asking for nothing is not an error, it is just nothing to record
if (command.Seats == 0)
{
return;
}

stream.AppendOne(new SeatsReserved(command.Seats));
}

// A command that spans two streams takes two stream handles
public static void Handle(TransferSeats command, IEventStream<Booking> from, IEventStream<Booking> to)
{
if (from.Id == to.Id)
{
from.AppendOne(new BookingRejected("Cannot transfer seats to the same booking"));
return;
}

from.AppendOne(new SeatsReleased(command.Seats));
to.AppendOne(new SeatsReserved(command.Seats));
}
}
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/StubEventStreamTests.cs#L11-L72' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_stub_event_stream_subject' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Asserting on the events a handler appends

Construct the stub with the aggregate state the handler should see, call the handler, and assert on
`EventsAppended`:

<!-- snippet: sample_stub_event_stream_basic_usage -->
<a id='snippet-sample_stub_event_stream_basic_usage'></a>
```cs
// Arrange -- the aggregate state the handler should see. No database, no mocks.
var stream = new StubEventStream<Booking>(new Booking(Guid.NewGuid(), 10, false));

// Act
BookingHandler.Handle(new ReserveSeats(stream.Id, 2), stream);

// Assert on what the handler actually produced
var reserved = stream.EventsAppended.Single().ShouldBeOfType<SeatsReserved>();
reserved.Seats.ShouldBe(2);
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/StubEventStreamTests.cs#L79-L91' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_stub_event_stream_basic_usage' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## A stream that does not exist yet

A `null` aggregate is exactly what a handler sees for a stream that has not been started, which is
how it chooses between starting a stream and appending to one:

<!-- snippet: sample_stub_event_stream_missing_stream -->
<a id='snippet-sample_stub_event_stream_missing_stream'></a>
```cs
// A null aggregate is what a handler sees for a stream that has not been started
var stream = new StubEventStream<Booking>(null);

BookingHandler.Handle(new ReserveSeats(Guid.NewGuid(), 4), stream);

stream.EventsAppended.Single().ShouldBeOfType<BookingStarted>()
.Seats.ShouldBe(4);
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/StubEventStreamTests.cs#L97-L107' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_stub_event_stream_missing_stream' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Deciding to do nothing

"This command required no events" is a real outcome and worth its own test:

<!-- snippet: sample_stub_event_stream_nothing_appended -->
<a id='snippet-sample_stub_event_stream_nothing_appended'></a>
```cs
var stream = new StubEventStream<Booking>(new Booking(Guid.NewGuid(), 10, false));

BookingHandler.Handle(new ReserveSeats(stream.Id, 0), stream);

// "The handler decided to do nothing" is a real outcome, and one a mock
// verification of AppendOne() cannot state directly
stream.EventsAppended.ShouldBeEmpty();
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/StubEventStreamTests.cs#L113-L123' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_stub_event_stream_nothing_appended' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## A command that spans two streams

A handler that takes two stream handles gets two stubs. Set `Id` — or `Key`, if your streams are
identified by string — so the identities the handler reads line up with the command:

<!-- snippet: sample_stub_event_stream_multiple_streams -->
<a id='snippet-sample_stub_event_stream_multiple_streams'></a>
```cs
// Set Id (or Key, for a string identified stream) so the command's identities
// line up with the streams the handler was handed
var from = new StubEventStream<Booking>(new Booking(Guid.NewGuid(), 10, false)) { Id = Guid.NewGuid() };
var to = new StubEventStream<Booking>(new Booking(Guid.NewGuid(), 0, false)) { Id = Guid.NewGuid() };

BookingHandler.Handle(new TransferSeats(from.Id, to.Id, 3), from, to);

from.EventsAppended.Single().ShouldBeOfType<SeatsReleased>().Seats.ShouldBe(3);
to.EventsAppended.Single().ShouldBeOfType<SeatsReserved>().Seats.ShouldBe(3);
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/StubEventStreamTests.cs#L129-L141' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_stub_event_stream_multiple_streams' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## `EventsAppended` vs `Events`

`EventsAppended` holds the raw event bodies the handler emitted, and is what most assertions want.
`Events` wraps those same bodies in `IEvent` envelopes for a handler or assertion that reads the
interface member itself:

<!-- snippet: sample_stub_event_stream_events_vs_events_appended -->
<a id='snippet-sample_stub_event_stream_events_vs_events_appended'></a>
```cs
var stream = new StubEventStream<Booking>(new Booking(Guid.NewGuid(), 10, false));
BookingHandler.Handle(new ReserveSeats(stream.Id, 2), stream);

// EventsAppended holds the raw event bodies the handler emitted -- what most
// assertions want
stream.EventsAppended.Single().ShouldBeOfType<SeatsReserved>();

// Events wraps those same bodies in IEvent envelopes, for a handler or an
// assertion that reads the interface member itself
var envelope = stream.Events.Single();
envelope.Data.ShouldBeOfType<SeatsReserved>();
envelope.EventType.ShouldBe(typeof(SeatsReserved));
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/StubEventStreamTests.cs#L160-L175' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_stub_event_stream_events_vs_events_appended' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The envelopes carry the event type naming and nothing a real store would only know at save time:
no sequence, no stream version, no server timestamp.

## Versions and identity

`Id`, `Key`, `StartingVersion` and `CurrentVersion` are all settable, so a handler whose logic reads
any of them can be exercised:

<!-- snippet: sample_stub_event_stream_versions_and_identity -->
<a id='snippet-sample_stub_event_stream_versions_and_identity'></a>
```cs
var stream = new StubEventStream<Booking>(new Booking(Guid.NewGuid(), 10, false))
{
// The identity the handler reads off the stream
Id = Guid.NewGuid(),
Key = "booking-1",

// The stream version the handler sees, for logic that cares about it
StartingVersion = 4,
CurrentVersion = 4
};

stream.StartingVersion.ShouldBe(4);
stream.CurrentVersion.ShouldBe(4);
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/StubEventStreamTests.cs#L181-L197' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_stub_event_stream_versions_and_identity' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Both `Id` and `Key` are populated with a new value by default, because the stub cannot know which
identity style the handler under test reads.

## Why not a mocking library?

The obvious alternative is `Substitute.For<IEventStream<Account>>()` followed by
`stream.Received(1).AppendOne(Arg.Any<Withdrawn>())`. That verifies *a method was called*. It says
nothing about **which** event was appended or what it carried — which is the entire content of the
decision the handler makes — and it breaks on a behavior-preserving refactor, such as one
`AppendMany` in place of two `AppendOne` calls.

A recorded list of events is the thing the handler is actually supposed to produce. Asserting on it
survives that refactor and fails when the decision changes, which is the only time you want a unit
test to fail.

## What the stub does not do

::: warning
The stub **records**. It does not persist, project, validate, or apply optimistic concurrency, and
it will never throw `ConcurrencyException`. `TryFastForwardVersion()` is a no-op.
:::

Anything that depends on the database's behavior rather than on your handler's logic belongs in an
[integration test](/testing/integration):

- optimistic concurrency and `ConcurrencyException`
- whether a projection produces the right document from those events
- event type aliases as they are actually persisted and read back
- multi-tenancy, and anything involving the async daemon

## Marten's own `StubEventStream<T>`

`Marten.Events.StubEventStream<T>` has existed since long before the JasperFx one and still works —
it is now a thin subclass that adds a `StoreOptions` overload, for the rare test that asserts on
event type *names* under customized aliases:

```cs
var options = new StoreOptions();
options.Events.MapEventType<SeatsReserved>("seats_taken");

var stream = new Marten.Events.StubEventStream<Booking>(null, options);
stream.AppendOne(new SeatsReserved(2));

stream.Events.Single().EventTypeName.ShouldBe("seats_taken");
```

Reach for `JasperFx.Events.StubEventStream<T>` otherwise. It is the one that reads the same in every
Critter Stack store, and Marten's subclass is expected to be marked obsolete on the next major.

::: tip
The two types have the same name in different namespaces, so a test file with both
`using Marten.Events;` and `using JasperFx.Events;` will not compile until you qualify one of them.
:::
Loading
Loading