Testing
Section titled “Testing”Guide to testing Orleans.FSharp grains.
Current API. This guide covers
grainContract,grainFor, andjournaledGrainFor. Tests for earlier authoring models live under Legacy Testing.
What you’ll learn
Section titled “What you’ll learn”- How to call context-free handler functions directly
- How to test a functional grain with a real
TestingHostcluster - How to use
TestHarnessfor integration tests - How to generate property-test data with
GrainArbitraryand FsCheck - How to test journal folds and snapshot behavior
- How to capture and assert on log entries
Installation
Section titled “Installation”dotnet add package Orleans.FSharp.Testingdotnet add package FsCheckdotnet add package FsCheck.Xunitdotnet add package xunitdotnet add package Microsoft.Orleans.TestingHostTesting a Functional Grain
Section titled “Testing a Functional Grain”A handler that does not inspect FunctionalGrainContext remains an ordinary function and can be
called directly. Full activation behavior uses a lightweight TestingHost cluster, driven through
FunctionalGrain.ref exactly as production code would, because application code cannot fabricate
the runtime-owned context. counterDefinition and CounterApi below are the same
grainContract / grainFor pair shown in Getting Started:
open Systemopen System.Threading.Tasksopen Orleansopen Orleans.Hostingopen Orleans.TestingHostopen Orleans.FSharpopen Xunit
type CounterActor = private CounterActor of unit
[<NoEquality; NoComparison>]type CounterApi = { increment: unit -> Task<int> value: unit -> Task<int> }
[<RequireQualifiedAccess>]module CounterApi = let contract = grainContract<CounterActor, string, CounterApi> { grainType "counter" version 1 stringKey readOnly (_.value) }
let ref = FunctionalGrain.ref contract
let counterDefinition = grainFor CounterApi.contract { defaultState (fun () -> 0) handle (_.increment) (fun _ count () -> task { let next = count + 1 return next, next }) handleQuery (_.value) (fun _ count () -> task { return count }) }
type CounterSiloConfigurator() = interface ISiloConfigurator with member _.Configure(siloBuilder: ISiloBuilder) = siloBuilder.AddMemoryGrainStorage("Default") |> ignore siloBuilder.AddFunctionalGrain(counterDefinition) |> ignore
type CounterClientConfigurator() = interface IClientBuilderConfigurator with member _.Configure(_configuration, clientBuilder: IClientBuilder) = clientBuilder.AddFunctionalGrainClient() |> ignore
type CounterClusterFixture() = let cluster = let builder = TestClusterBuilder(1s) builder.AddSiloBuilderConfigurator<CounterSiloConfigurator>() |> ignore builder.AddClientBuilderConfigurator<CounterClientConfigurator>() |> ignore let cluster = builder.Build() cluster.Deploy() cluster
member _.Client = cluster.Client
interface IDisposable with member _.Dispose() = cluster.StopAllSilos()
type CounterFunctionalTests(fixture: CounterClusterFixture) = interface IClassFixture<CounterClusterFixture>
[<Fact>] member _.``increment round-trips through a real activation``() = task { let api = CounterApi.ref fixture.Client "test-counter" let! c1 = api.increment () let! c2 = api.increment () Assert.Equal(1, c1) Assert.Equal(2, c2) }GrainArbitrary.forState<'State>() works directly because a functional grain’s state is an
ordinary F# type, DU or otherwise. GrainArbitrary.forCommands<'Command>() is less direct: it
targets one command discriminated union, while a functional API
record is several independently typed operations rather than one sum type. A property test over a
functional grain instead drives the API record’s operations directly in a loop (or the raw handler
functions, for the context-free case above) rather than folding over a generated 'Command list.
TestHarness
Section titled “TestHarness”TestHarness wraps an Orleans TestCluster with integrated log capture. It provides a real silo for integration tests.
Create a test cluster
Section titled “Create a test cluster”open Orleans.FSharp.Testing
let createHarness () = TestHarness.createTestCluster()This starts a single-silo cluster with:
- In-memory grain storage (named “Default”)
- In-memory streams (named “StreamProvider”)
- PubSubStore for streams
- A log capturing factory
Create with custom configuration
Section titled “Create with custom configuration”open Orleans.FSharp.Runtime
let config = siloConfig { useLocalhostClustering addMemoryStorage "Default" addMemoryStorage "Archive" addMemoryReminderService}
let createCustomHarness () = TestHarness.createTestClusterWith configCapture logs
Section titled “Capture logs”open Microsoft.Extensions.Loggingopen Xunit
let assertNoWarnings harness = let logs = TestHarness.captureLogs harness
for entry in logs do printfn "[%A] %s" entry.Level entry.Template
logs |> List.filter (fun entry -> entry.Level = LogLevel.Warning) |> Assert.EmptyReset and dispose
Section titled “Reset and dispose”let resetAndDispose harness = task { do! TestHarness.reset harness do! TestHarness.dispose harness }GrainArbitrary
Section titled “GrainArbitrary”GrainArbitrary auto-generates FsCheck Arbitrary instances for F# discriminated unions by inspecting the DU structure at runtime. No manual generator code needed.
Generate state values
Section titled “Generate state values”open Orleans.FSharp.Testing
type CounterState = | Zero | Count of int
type CounterCommand = | Increment | Decrement | GetValue
let arb = GrainArbitrary.forState<CounterState>()
// Use in a property testlet gen = arb |> FsCheck.FSharp.Arb.toGenlet sample = gen |> FsCheck.FSharp.Gen.sampleWithSize 10 5// Produces: [Zero; Count 42; Count -7; Zero; Count 1]Generate command sequences
Section titled “Generate command sequences”let arb = GrainArbitrary.forCommands<CounterCommand>()
// Produces non-empty lists like:// [Increment; Decrement; GetValue; Increment]// [GetValue]// [Decrement; Increment; Increment]GrainArbitrary handles:
- Fieldless DU cases (e.g.,
Zero,Increment) - Single-field cases (e.g.,
Count of int) - Multi-field cases (e.g.,
Transfer of amount: decimal * account: string) - Nested DUs, records, options, lists
- Falls back to FsCheck default generators for primitive types
FsCheckHelpers
Section titled “FsCheckHelpers”The FsCheckHelpers module provides property test utilities.
State machine property
Section titled “State machine property”Verify that an invariant holds for any sequence of commands:
open Orleans.FSharp.Testing
let counterInvariant state = match state with | Zero -> true | Count n -> n > 0
let applyCommand state cmd = match state, cmd with | Zero, Increment -> Count 1 | Zero, Decrement -> Zero | Count n, Increment -> Count(n + 1) | Count n, Decrement when n > 1 -> Count(n - 1) | Count _, Decrement -> Zero | s, GetValue -> s
[<Property>]let ``counter is never negative`` () = let arb = GrainArbitrary.forCommands<CounterCommand>() Prop.forAll arb (fun commands -> FsCheckHelpers.stateMachineProperty Zero applyCommand counterInvariant commands)Command sequence arbitrary
Section titled “Command sequence arbitrary”Generate random command sequences using default FsCheck arbitraries:
let arb = FsCheckHelpers.commandSequenceArb<CounterCommand>()Log Capture
Section titled “Log Capture”The LogCapture module provides in-memory log capture for test assertions.
Create a capturing factory
Section titled “Create a capturing factory”let logFactory = LogCapture.create()let logger = (logFactory :> ILoggerFactory).CreateLogger("Test")
logger.LogInformation("Hello {Name}", "World")
let entries = LogCapture.captureLogs logFactoryAssert.Single(entries) |> ignoreAssert.Equal("Hello {Name}", entries.[0].Template)Assert.Equal("World", entries.[0].Properties.["Name"] :?> string)CapturedLogEntry
Section titled “CapturedLogEntry”Each entry contains:
| Field | Type | Description |
|---|---|---|
Level |
LogLevel |
Information, Warning, Error, Debug, etc. |
Template |
string |
The structured log template |
Properties |
Map<string, obj> |
Template argument values |
Timestamp |
DateTimeOffset |
When the entry was captured |
Exception |
exn option |
Associated exception, if any |
Testing a journaled grain
Section titled “Testing a journaled grain”Keep the event fold as a named pure function and test it directly. Use a TestingHost cluster for confirmation, provider, snapshot, timer, reminder, stream, and broadcast behavior.
let applyAccount state event = match event with | Deposited amount -> { state with balance = state.balance + amount } | Withdrawn amount -> { state with balance = state.balance - amount }
[<Fact>]let ``apply folds a deposit`` () = let initial = { balance = 10m } Assert.Equal({ balance = 15m }, applyAccount initial (Deposited 5m))The integration fixture registers the definition with AddFunctionalJournaledGrain, configures one
of Orleans’ log-consistency providers, then calls the typed API via FunctionalGrain.ref. Snapshot
policy tests should use a custom storage adapter and assert the stored version and state, not only
the reply.
Testing rolling updates and durable schemas
Section titled “Testing rolling updates and durable schemas”Do not model an N/N+1 deployment by loading both definitions into one test assembly. The repository
builds and starts two separate silo executables, keeps both alive in one cluster, exercises their
native interface versions, and then removes N+1 to verify rollback. The harness is
RollingUpdateIntegrationTests.fs; the binaries live in Orleans.FSharp.Rolling.V1 and .V2.
RollingDurableIntegrationTests.fs adds a separate-process data-evolution scenario. N writes old
state and events to retained files; N+1 upgrades an existing identity while N is still running,
and both old API calls and the new operation are exercised. Fresh processes then verify recovery,
rollback to a bridge with compatible readers, and explicit refusal by the original old reader
without changing the durable files.
The scenario covers ordinary versioned state, native StateStorage journal views, native
LogStorage events, and a typed CustomStorage snapshot plus retained tail. The custom store owns
its versioned JSON format and pure mappings; it does not imply that runtime envelope upcasters
automatically migrate a user’s database. The test checks old/new tail versions, snapshot contents
after compaction, and exact folded totals after restart. Its local file CAS and atomic replacement
do not certify power-loss behavior or arbitrary third-party database providers.
For persistence compatibility, keep byte fixtures written by historical serializers. The current
suite has two layers: pre-schema envelope fixtures prove an omitted field becomes schema version
0; released v4.1.0 journal view/entry fixtures contain a real F# binary EvolutionV0 payload
and are decoded directly through the current typed upcaster pipeline. Fixture provenance is kept
beside the bytes in tests/Orleans.FSharp.Tests/Fixtures/README.md. A test which merely creates an
old-looking object using the new binary does not prove backward compatibility.
Next steps
Section titled “Next steps”- Functional Grain Runtime – the current authoring model
- Release and Production Status – what the repository checks do and do not prove
- Event Sourcing – testing event-sourced grains
- Advanced – transactions, serialization, and more