Test your domain
Test the domain at four levels: individual decisions, individual folds, replayed histories, and repeated commands. These tests call pure functions directly and need no actor system or database.
Use fixed envelope values so failures are reproducible:
let fixedTime = System.DateTime(2026, 1, 1, 12, 0, 0, System.DateTimeKind.Utc)
let command details : Command<_> =
{ CommandDetails = details
CreationDate = fixedTime
Id = Guid.CreateVersion7().ToString() |> ValueLens.CreateAsResult |> Result.value
Sender = None
CorrelationId = Fcqrs.newCid ()
Metadata = Map.empty }
let event version details : Event<_> =
{ EventDetails = details
CreationDate = fixedTime
Id = Guid.CreateVersion7().ToString() |> ValueLens.CreateAsResult |> Result.value
Sender = None
CorrelationId = Fcqrs.newCid ()
Version = version |> ValueLens.TryCreate |> Result.value
Metadata = Map.empty }
|
FakeTimeProvider comes from the Microsoft.Extensions.TimeProvider.Testing package. Use
TimeProvider.System when the rule does not inspect the envelope time and a fixed clock adds no value.
The examples below use Expect.equal from Expecto. Use the equivalent equality assertion in another
test framework.
Test the decision table
These use the Document from the tutorial. CreateOrUpdate
produces Updated.
let doc =
Document.Root.TryCreate(System.Guid.NewGuid(), "Spec", "draft") |> Result.value
// a write persists Updated
let action = Document.decide (command (Document.CreateOrUpdate doc)) Document.initial
Expect.equal
action
(PersistEvent (Document.Updated doc))
"creating a document stores Updated"
|
For an idempotent verdict, such as publication confirmation in chapter 3, assert the deferred event the same way:
let publishedState: Document.State =
{ Document = Some doc
Publication = Document.Finished("guides/fcqrs", Document.Published) }
// reporting the same result again does not add another journal entry
let action2 =
Document.decide
(command (Document.FinishPublication Document.Published))
publishedState
Expect.equal
action2
(DeferEvent(
Document.PublicationFinished(doc.Id, "guides/fcqrs", Document.Published)))
"repeating the result defers the existing publication outcome"
|
Write one case for every meaningful command and state combination, including commands that should be ignored or unhandled.
Test one fold
fold takes an event envelope and produces the next state:
let state = Document.fold (event 1L (Document.Updated doc)) Document.initial
Expect.equal state.Document (Some doc) "Updated becomes the current document"
|
The envelope version is maintained by FCQRS. Do not duplicate it in domain state unless the domain has a separate version concept with different meaning.
Test replay
Fold a complete history to verify recovery:
let edited = { doc with Content = "revised" }
let recovered =
[ event 1L (Document.Updated doc)
event 2L (Document.Updated edited) ]
|> List.fold (fun state stored -> Document.fold stored state) Document.initial
Expect.equal recovered.Document (Some edited) "replay recovers the latest document"
|
Use fixed events captured from an older release as compatibility fixtures. A replay test should fail if a changed fold can no longer reproduce the historical state.
Test retry behaviour
Call the same command against the state produced by its first event. A repeated command should not
repeat a business effect. For the chapter 3 document, confirming an already published document returns
a deferred Published reply instead of persisting another publication.
Also test boundary times and generated ids. Put the chosen value in the command or event; never let a fold read the live clock or random generator.
In C#, decide and fold are the aggregate's HandleCommand and ApplyEvent methods. The paired
examples use TestEnvelope and a FakeTimeProvider; no actor system or dependency-injection container
is started.
Test sagas in two layers
Test handleEvent by asserting the next saga state for each event and current state. Test
applySideEffects separately by asserting its transition, target aggregate id, command payload, and
delay. Include recovery cases for branches that treat recovering = true differently.
Use integration tests for actor routing, persistence plugins, projection transactions, and recovery across an actual process restart. Pure domain tests do not prove those infrastructure paths.
See Aggregates and Testing and evolution.
<summary> Contains common types like Events and Commands </summary>
<namespacedoc><summary>Functionality for Write Side.</summary></namespacedoc>
<summary> Idiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...) </summary>
[<Struct>] type Guid = new: b: byte array -> unit + 6 overloads member CompareTo: value: Guid -> int + 1 overload member Equals: g: Guid -> bool + 1 overload member GetHashCode: unit -> int member ToByteArray: unit -> byte array + 1 overload member ToString: unit -> string + 2 overloads member TryFormat: utf8Destination: Span<byte> * bytesWritten: byref<int> * ?format: ReadOnlySpan<char> -> bool + 1 overload member TryWriteBytes: destination: Span<byte> -> bool + 1 overload static member (<) : left: Guid * right: Guid -> bool static member (<=) : left: Guid * right: Guid -> bool ...
<summary>Represents a globally unique identifier (GUID).</summary>
--------------------
Guid ()
Guid(b: byte array) : Guid
Guid(b: ReadOnlySpan<byte>) : Guid
Guid(g: string) : Guid
Guid(b: ReadOnlySpan<byte>, bigEndian: bool) : Guid
Guid(a: int, b: int16, c: int16, d: byte array) : Guid
Guid(a: int, b: int16, c: int16, d: byte, e: byte, f: byte, g: byte, h: byte, i: byte, j: byte, k: byte) : Guid
Guid(a: uint32, b: uint16, c: uint16, d: byte, e: byte, f: byte, g: byte, h: byte, i: byte, j: byte, k: byte) : Guid
val string: value: 'T -> string
--------------------
type string = String
type String = interface IEnumerable<char> interface IEnumerable interface ICloneable interface IComparable interface IComparable<string> interface IConvertible interface IEquatable<string> interface IParsable<string> interface ISpanParsable<string> new: value: nativeptr<char> -> unit + 8 overloads ...
<summary>Represents text as a sequence of UTF-16 code units.</summary>
--------------------
String(value: nativeptr<char>) : String
String(value: char array) : String
String(value: ReadOnlySpan<char>) : String
String(value: nativeptr<sbyte>) : String
String(c: char, count: int) : String
String(value: nativeptr<char>, startIndex: int, length: int) : String
String(value: char array, startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int, enc: Text.Encoding) : String
type State = { Document: Root option Publication: PublicationStatus }
--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
type Command = | CreateOrUpdate of Root | Publish of slug: string | FinishPublication of PublicationResult
--------------------
type Command<'CommandDetails> = { CommandDetails: 'CommandDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Command<'CommandDetails> * IEqualityComparer -> bool override ToString: unit -> string
<summary> Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam> </summary>
--------------------
type Command<'Command,'Event> = | Execute of CommandDetails<'Command,'Event>
<summary> Represents the message sent to the internal subscription mechanism. <typeparam name="'Command">The type of the command payload.</typeparam> <typeparam name="'Event">The type of the expected event payload.</typeparam> </summary>
module Event from Microsoft.FSharp.Control
--------------------
type Event = | Updated of Root | PublicationRequested of Guid * slug: string | PublicationFinished of Guid * slug: string * PublicationResult
--------------------
type Event<'EventDetails> = { EventDetails: 'EventDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Version: Version Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Event<'EventDetails> * IEqualityComparer -> bool override ToString: unit -> string member Journaled: bool option
<summary> Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam> </summary>
--------------------
type Event<'Delegate,'Args (requires delegate and 'Delegate :> Delegate and reference type)> = new: unit -> Event<'Delegate,'Args> member Trigger: sender: obj * args: 'Args -> unit member Publish: IEvent<'Delegate,'Args>
--------------------
new: unit -> Event<'Delegate,'Args>
<summary> Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam> </summary>
<summary> The specific details or payload of the command. </summary>
<summary> Persist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds. </summary>
<summary> Publish and fold the event in the live actor without storing it or incrementing the persisted version. </summary>
<summary> Indicate that the command or event could not be handled in the current state. </summary>
<summary> Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam> </summary>
<summary> The specific details or payload of the event. </summary>
[<Struct>] type DateTime = new: date: DateOnly * time: TimeOnly -> unit + 16 overloads member Add: value: TimeSpan -> DateTime member AddDays: value: float -> DateTime member AddHours: value: float -> DateTime member AddMicroseconds: value: float -> DateTime member AddMilliseconds: value: float -> DateTime member AddMinutes: value: float -> DateTime member AddMonths: months: int -> DateTime member AddSeconds: value: float -> DateTime member AddTicks: value: int64 -> DateTime ...
<summary>Represents an instant in time, typically expressed as a date and time of day.</summary>
--------------------
DateTime ()
(+0 other overloads)
DateTime(ticks: int64) : DateTime
(+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly) : DateTime
(+0 other overloads)
DateTime(ticks: int64, kind: DateTimeKind) : DateTime
(+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly, kind: DateTimeKind) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, calendar: Globalization.Calendar) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, kind: DateTimeKind) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, calendar: Globalization.Calendar) : DateTime
(+0 other overloads)
<summary>Specifies whether a <see cref="T:System.DateTime" /> object represents a local time, a Coordinated Universal Time (UTC), or is not specified as either local time or UTC.</summary>
type Command<'CommandDetails> = { CommandDetails: 'CommandDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Command<'CommandDetails> * IEqualityComparer -> bool override ToString: unit -> string
<summary> Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam> </summary>
--------------------
type Command<'Command,'Event> = | Execute of CommandDetails<'Command,'Event>
<summary> Represents the message sent to the internal subscription mechanism. <typeparam name="'Command">The type of the command payload.</typeparam> <typeparam name="'Event">The type of the expected event payload.</typeparam> </summary>
Guid.CreateVersion7(timestamp: DateTimeOffset) : Guid
module Result from FCQRS.Model.Data
--------------------
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
<summary> Specifies the target as the original sender of the message that triggered the current saga step. NOTE: side effects run inside persist re-injections, where the ambient sender is the journal actor, or in the subscription-ack re-drive, where it is the pub-sub mediator — never the original trigger. Commands to Sender therefore dead-letter; the saga logs a warning at resolution. Use FactoryAndName with Originator to reach the originator instead. </summary>
<summary> A fresh correlation id (UUID v7). </summary>
module Map from Microsoft.FSharp.Collections
--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...
--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
module Event from Microsoft.FSharp.Control
--------------------
type Event<'EventDetails> = { EventDetails: 'EventDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Version: Version Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Event<'EventDetails> * IEqualityComparer -> bool override ToString: unit -> string member Journaled: bool option
<summary> Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam> </summary>
--------------------
type Event<'Delegate,'Args (requires delegate and 'Delegate :> Delegate and reference type)> = new: unit -> Event<'Delegate,'Args> member Trigger: sender: obj * args: 'Args -> unit member Publish: IEvent<'Delegate,'Args>
--------------------
new: unit -> Event<'Delegate,'Args>
<summary> Aggregate Version </summary>
<summary> A module for specifying what you expect from the values generated by your tests. </summary>
<summary> Expects the two values to equal each other. </summary>
module List from Microsoft.FSharp.Collections
--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
FCQRS