Test your domain
Test the account from the tutorial's withdraw money step directly,
without starting FCQRS or SQLite. TestEnvelope wraps your payload in the same command or event type
the runtime passes to your code.
Check decisions, replay, and rejections
The F# assertions use Expecto. The C# tests use xUnit and the sample's Account class.
Shared setup
module Account =
open FCQRS.Common
// What a caller can ask an account to do.
type AccountCommand =
| Open of owner: string
| Deposit of amount: decimal
| Withdraw of amount: decimal
// What the account replies. Rejected is a reply only: it is never stored.
type AccountEvent =
| Opened of owner: string
| Deposited of amount: decimal
| Withdrawn of amount: decimal
| Rejected of reason: string
// What the account knows now, rebuilt from its events.
type AccountState = { Owner: string option; Balance: decimal }
// The state before the account's first event.
let initial = { Owner = None; Balance = 0m }
// Chooses what to do with a command, based on the current state.
let decide (command: Command<AccountCommand>) (state: AccountState) =
match command.CommandDetails, state.Owner with
| Open _, Some _ -> DeferEvent(Rejected "The account is already open")
| Open owner, None -> PersistEvent(Opened owner)
| _, None -> DeferEvent(Rejected "The account is not open")
| (Deposit amount | Withdraw amount), _ when amount <= 0m ->
DeferEvent(Rejected "The amount must be positive")
| Deposit amount, _ -> PersistEvent(Deposited amount)
| Withdraw amount, _ when amount > state.Balance ->
DeferEvent(Rejected $"Insufficient funds: {state.Balance} available")
| Withdraw amount, _ -> PersistEvent(Withdrawn amount)
// Applies one event to the state. A rejection changes nothing.
let fold (event: Event<AccountEvent>) (state: AccountState) =
match event.EventDetails with
| Opened owner -> { state with Owner = Some owner }
| Deposited amount -> { state with Balance = state.Balance + amount }
| Withdrawn amount -> { state with Balance = state.Balance - amount }
| Rejected _ -> state
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_450-how-to_008-test-your-domain.md_page.Account.AccountCommandOpenowner: stringstringAn abbreviation for the CLI type . Basic Types
Depositamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
WithdrawFcqrs_450-how-to_008-test-your-domain.md_page.Account.AccountEventOpenedDepositedWithdrawnRejectedreason: stringFcqrs_450-how-to_008-test-your-domain.md_page.Account.AccountStateOwner: 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
Balance: decimalinitial: AccountStateNoneThe representation of "No value"
decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>command: Command<AccountCommand>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: AccountStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version. A deferred event does not start a saga; running sagas still receive it.
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
(>): 'T -> 'T -> boolStructural greater-than The first parameter. The second parameter. The result of the comparison. 5 > 1 // Evaluates to true 5 > 5 // Evaluates to false (1, "a") > (1, "z") // Evaluates to false
fold: Event<AccountEvent> -> AccountState -> AccountStateevent: Event<AccountEvent>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>
EventDetails: 'EventDetailsThe specific details or payload of the event.
SystemModelFCQRS.Model.DataFCQRS.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 / Projections.startTracked / 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 -> ...)
ExpectoFCQRS.CSharpC# interoperability helpers for FCQRS Provides simpler APIs for consuming FCQRS from C#
// An account Alice opened, with a balance of 70.
let opened = { initial with Owner = Some "Alice"; Balance = 70m }
// A withdrawal the balance covers is stored.
let accepted = decide (TestEnvelope.Command(Withdraw 60m)) opened
Expect.equal accepted (PersistEvent(Withdrawn 60m)) "a covered withdrawal persists"
// Recovery applies the stored events in order, starting from the initial state.
let history =
[ Opened "Alice"; Deposited 100m; Withdrawn 30m ]
|> List.mapi (fun index event -> TestEnvelope.Event(event, int64 (index + 1)))
let recovered = List.fold (fun state event -> fold event state) initial history
Expect.equal recovered opened "replay restores the balance"
// A larger withdrawal is rejected, and folding the rejection changes nothing.
let rejection = Rejected "Insufficient funds: 70 available"
let overdraft = decide (TestEnvelope.Command(Withdraw 500m)) recovered
Expect.equal overdraft (DeferEvent rejection) "an overdraft is rejected"
let reply = TestEnvelope.Event(rejection, 3L)
Expect.equal (fold reply recovered) recovered "the rejection preserves state"
printfn "Account tests passed."
opened: AccountStateFcqrs_450-how-to_008-test-your-domain.md_page.Account.AccountStateinitial: AccountStateOwner: string optionBalance: decimalaccepted: EventAction<AccountEvent>decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>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.
WithdrawExpecto.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.
Withdrawnhistory: Event<AccountEvent> listOpenedDeposited(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Collections.ListModuleContains operations for working with values of type . Operations for collections such as lists, arrays, sets, maps and sequences. See also F# Collection Types in the F# Language Guide.
mapi: (int -> 'T -> 'U) -> 'T list -> 'U listBuilds a new collection whose elements are the results of applying the given function to each of the elements of the collection. The integer index passed to the function indicates the index (from 0) of the element being transformed. The function to transform elements and their indices. The input list. The list of transformed elements. let inputs = [ 10; 10; 10 ] inputs |> List.mapi (fun i x -> i + x) Evaluates to [ 10; 11; 12 ]
index: intevent: AccountEventEvent: 'T * int64 -> Event<'T>Wrap an event payload in an Event envelope using the system clock.
int64: ^T -> int64Converts the argument to signed 64-bit integer. This is a direct conversion for all primitive numeric types. For strings, the input is converted using Int64.Parse() with InvariantCulture settings. Otherwise the operation requires an appropriate static conversion method on the input type. The input value. The converted int64 int64 'A' // evaluates to 65L int64 0xff // evaluates to 255L int64 -10 // evaluates to -10L
(+): ^T1 -> ^T2 -> ^T3Overloaded addition operator The first parameter. The second parameter. The result of the operation. 2 + 2 // Evaluates to 4 "Hello " + "World" // Evaluates to "Hello World"
recovered: AccountStatefold: ('State -> 'T -> 'State) -> 'State -> 'T list -> 'StateApplies a function to each element of the collection, threading an accumulator argument through the computation. Take the second argument, and apply the function to it and the first element of the list. Then feed this result into the function along with the second element and so on. Return the final result. If the input function is f and the elements are i0...iN then computes f (... (f s i0) i1 ...) iN. The function to update the state given the input elements. The initial state. The input list. The final state value. Making the sum of squares for the first 5 natural numbers (0, [1..5]) ||> List.fold (fun s v -> s + v * v) // evaluates 55 Shopping for fruits hungry, you tend to take more of each as the hunger grows type Fruit = Apple | Pear | Orange type BagItem = { fruit: Fruit; quantity: int } let takeMore (previous: BagItem list) fruit = let toTakeThisTime = match previous with | bagItem :: otherBagItems -> bagItem.quantity + 1 | [] -> 1 { fruit = fruit; quantity = toTakeThisTime } :: previous let inputs = [ Apple; Pear; Orange ] ([], inputs) ||> List.fold takeMore Evaluates to [{ fruit = Orange; quantity = 3 } { fruit = Pear; quantity = 2 } { fruit = Apple; quantity = 1 }]
state: AccountStateevent: Event<AccountEvent>fold: Event<AccountEvent> -> AccountState -> AccountStaterejection: AccountEventRejectedoverdraft: EventAction<AccountEvent>DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version. A deferred event does not start a saga; running sagas still receive it.
reply: Event<AccountEvent>printfn: Printf.StringFormat<'a,unit> -> 'aExpecto atomic printfn shadow function
using Xunit;
using static FCQRS.CSharp;
public class AccountTests
{
private readonly Account account = new();
// An account Alice opened, with a balance of 70.
private readonly AccountState opened = new("Alice", 70m);
[Fact]
public void Covered_withdrawal_is_persisted()
{
// Name the union type: the aggregate expects a Command<AccountCommand>.
var command = TestEnvelope.Command<AccountCommand>(new Withdraw(60m));
Assert.Equal(
EventActions.Persist<AccountEvent>(new Withdrawn(60m)),
account.HandleCommand(command, opened));
}
[Fact]
public void Replay_restores_the_balance()
{
AccountEvent[] history =
[new Opened("Alice"), new Deposited(100m), new Withdrawn(30m)];
var state = account.InitialState;
for (var index = 0; index < history.Length; index++)
state = account.ApplyEvent(
TestEnvelope.Event(history[index], index + 1), state);
Assert.Equal(opened, state);
}
[Fact]
public void Overdraft_is_rejected_and_changes_nothing()
{
var rejection = new Rejected("Insufficient funds: 70 available");
var command = TestEnvelope.Command<AccountCommand>(new Withdraw(500m));
Assert.Equal(
EventActions.Defer<AccountEvent>(rejection),
account.HandleCommand(command, opened));
var reply = TestEnvelope.Event<AccountEvent>(rejection, 3);
Assert.Equal(opened, account.ApplyEvent(reply, opened));
}
}
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
cd samples/accounts
dotnet new xunit -n Accounts.Tests --framework net11.0
dotnet add Accounts.Tests reference 2-withdraw-money/csharp/Accounts.WithdrawMoney.CSharp.csproj
The global.json in samples/accounts selects the .NET 11 SDK. For C#, replace
Accounts.Tests/UnitTest1.cs with the test class above, then run dotnet test Accounts.Tests. All
three tests should pass. F# prints Account 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 the tutorial's first step does. For changes to stored event shapes, test compatibility with old events.