Test your domain
Test the registration account directly, without starting FCQRS or SQLite.
TestEnvelope wraps your payload in the same command or event type the runtime passes to your code.
Check registration, replay, and repeats
The F# assertions use Expecto. The C# tests use xUnit and the sample's Account class.
Shared setup
module Account =
open FCQRS.Common
open FCQRS.FSharp
type RegisterUser = RegisterUser of name: string
type UserRegistered = UserRegistered of name: string
let decide (command: Command<RegisterUser>) (state: string option) =
let (RegisterUser name) = command.CommandDetails
persistIf state.IsNone (UserRegistered(defaultArg state name))
let fold (event: Event<UserRegistered>) (_state: string option) =
let (UserRegistered name) = event.EventDetails
Some name
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
open Account
open Expecto
open FCQRS.CSharp
Fcqrs_450-how-to_008-test-your-domain.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-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 -> ...)
Fcqrs_450-how-to_008-test-your-domain.md_page.Account.RegisterUserRegisterUsername: stringstringAn abbreviation for the CLI type . Basic Types
Fcqrs_450-how-to_008-test-your-domain.md_page.Account.UserRegisteredUserRegistereddecide: Command<RegisterUser> -> string option -> EventAction<UserRegistered>command: Command<RegisterUser>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
state: string optionoptionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
CommandDetails: 'CommandDetailsThe specific details or payload of the command.
persistIf: bool -> 'e -> EventAction<'e>Persist the event when `shouldPersist`, else defer it (published and folded but not journalled). The deferred fold should preserve state, because it cannot be replayed. This is the idempotent "emit this verdict, write it only once" shape.
IsNone: boolReturn 'true' if the option is a 'None' value.
defaultArg: 'T option -> 'T -> 'TUsed to specify a default value for an optional argument in the implementation of a function An option representing the argument. The default value of the argument. The argument value. If it is None, the defaultValue is returned. type Vector(x: double, y: double, ?z: double) = let z = defaultArg z 0.0 member this.X = x member this.Y = y member this.Z = z let v1 = Vector(1.0, 2.0) v1.Z // Evaluates to 0. let v2 = Vector(1.0, 2.0, 3.0) v2.Z // Evaluates to 3.0
fold: Event<UserRegistered> -> string option -> string optionevent: Event<UserRegistered>FCQRS.Common.Event`1Represents 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>
_state: string optionEventDetails: 'EventDetailsThe specific details or payload of the event.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
SystemModelFCQRS.Model.DataExpectoFCQRS.CSharpC# interoperability helpers for FCQRS Provides simpler APIs for consuming FCQRS from C#
// A new account stores its first registration.
let first = decide (TestEnvelope.Command(RegisterUser "Alice")) None
Expect.equal first (PersistEvent(UserRegistered "Alice")) "first registration persists"
// Recovery starts with empty state and applies the stored event.
let stored = TestEnvelope.Event(UserRegistered "Alice", 1L)
let recovered = fold stored None
Expect.equal recovered (Some "Alice") "replay restores the name"
// A different requested name cannot overwrite the existing registration.
let repeated = decide (TestEnvelope.Command(RegisterUser "Bob")) recovered
Expect.equal repeated (DeferEvent(UserRegistered "Alice")) "repeat returns the saved name"
Expect.equal (fold stored recovered) recovered "applying the repeated reply preserves state"
printfn "Registration tests passed."
first: EventAction<UserRegistered>decide: Command<RegisterUser> -> string option -> EventAction<UserRegistered>FCQRS.CSharp.TestEnvelopeC#-friendly builders for the Command/Event envelopes that the pure handleCommand/applyEvent functions expect. Intended for unit tests: the envelope's plumbing fields (a fresh MessageId/CID, a UTC timestamp, no sender, empty metadata) are filled in for you, so a test supplies only the payload and, for events, the aggregate version. The framework builds these envelopes itself at runtime; tests are the one place you build them by hand.
Command: 'T -> Command<'T>Wrap a command payload in a Command envelope using the system clock.
RegisterUserNoneThe representation of "No value"
Expecto.ExpectA module for specifying what you expect from the values generated by your tests.
equal: 'a -> 'a -> string -> unitExpects the two values to equal each other.
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
UserRegisteredstored: Event<UserRegistered>Event: 'T * int64 -> Event<'T>Wrap an event payload in an Event envelope using the system clock.
recovered: string optionfold: Event<UserRegistered> -> string option -> string optionSomeThe representation of "Value of type 'T" The input value. An option representing the value.
repeated: EventAction<UserRegistered>DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version.
printfn: Printf.StringFormat<'a,unit> -> 'aExpecto atomic printfn shadow function
using Xunit;
using static FCQRS.CSharp;
public class AccountTests
{
private readonly Account account = new();
[Fact]
public void First_registration_is_persisted()
{
var action = account.HandleCommand(
TestEnvelope.Command(new RegisterUser("Alice")), account.InitialState);
Assert.Equal(EventActions.Persist(new UserRegistered("Alice")), action);
}
[Fact]
public void Replay_restores_the_name()
{
var stored = TestEnvelope.Event(new UserRegistered("Alice"), 1);
Assert.Equal(new AccountState("Alice"), account.ApplyEvent(stored, account.InitialState));
}
[Fact]
public void Repeated_registration_preserves_the_saved_name()
{
var state = new AccountState("Alice");
var action = account.HandleCommand(TestEnvelope.Command(new RegisterUser("Bob")), state);
Assert.Equal(EventActions.Defer(new UserRegistered("Alice")), action);
var reply = TestEnvelope.Event(new UserRegistered("Alice"), 1);
Assert.Equal(state, account.ApplyEvent(reply, state));
}
}
The last assertion matters because FCQRS applies deferred replies too. They must preserve recoverable state: a deferred change would disappear on restart.
Run the tests
From the repository root:
dotnet fsi --exec docs/how-to/test-your-domain.fsx
dotnet new xunit -n Registration.Tests --framework net10.0
dotnet add Registration.Tests reference samples/registration-csharp/Registration.CSharp.csproj
For C#, replace Registration.Tests/UnitTest1.cs with the test class above, then run
dotnet test Registration.Tests. All three tests should pass. F# prints Registration tests passed.
As your domain grows, add cases for each command and state combination and replay complete stored
histories. Use a fixed TimeProvider with TestEnvelope when a rule depends on the envelope time.
Keep clocks and external calls out of the fold.
These tests verify the rule and replay function. Also run the application across a real restart to check persistence and query recovery, as in the quickstart. For changes to stored event shapes, test compatibility with old events.