Testing
State machines generated by ZeroAlloc.StateMachine are plain .NET classes. No mocking framework is needed — you instantiate the machine directly, fire triggers, and assert on Current.
Basic state transition tests
public enum State { Idle, Pending, Done }
public enum Trigger { Submit, Pay }
[StateMachine(InitialState = nameof(State.Idle))]
[Transition<State, Trigger>(From = State.Idle, On = Trigger.Submit, To = State.Pending)]
[Transition<State, Trigger>(From = State.Pending, On = Trigger.Pay, To = State.Done)]
[Terminal<State>(State = State.Done)]
public partial class OrderMachine { }
// ── Tests ───────────────────────────────────────────────────────────────────
public class OrderMachineTests
{
[Fact]
public void InitialState_IsIdle()
{
var machine = new OrderMachine();
machine.Current.Should().Be(State.Idle);
}
[Fact]
public void Submit_TransitionsToP ending()
{
var machine = new OrderMachine();
machine.TryFire(Trigger.Submit).Should().BeTrue();
machine.Current.Should().Be(State.Pending);
}
[Fact]
public void Pay_FromPending_TransitionsToDone()
{
var machine = new OrderMachine();
machine.TryFire(Trigger.Submit);
machine.TryFire(Trigger.Pay).Should().BeTrue();
machine.Current.Should().Be(State.Done);
}
[Fact]
public void InvalidTrigger_ReturnsFalse_StateUnchanged()
{
var machine = new OrderMachine();
machine.TryFire(Trigger.Pay).Should().BeFalse(); // Pay not valid from Idle
machine.Current.Should().Be(State.Idle);
}
[Fact]
public void TerminalState_CannotTransition()
{
var machine = new OrderMachine();
machine.TryFire(Trigger.Submit);
machine.TryFire(Trigger.Pay);
machine.Current.Should().Be(State.Done);
machine.TryFire(Trigger.Submit).Should().BeFalse(); // Done is terminal
machine.Current.Should().Be(State.Done);
}
}
Testing guards
Guards are partial methods on your machine class. In tests, expose the controlling condition through a property on your machine subclass, or set it directly if it is a public field.
[Transition<State, Trigger>(From = State.Pending, On = Trigger.Pay, To = State.Done, When = true)]
public partial class OrderMachine
{
public bool HasSufficientBalance { get; set; }
private partial bool GuardPay(State from, Trigger on) => HasSufficientBalance;
}
public class GuardTests
{
[Fact]
public void Pay_BlockedWhenGuardFails()
{
var machine = new OrderMachine { HasSufficientBalance = false };
machine.TryFire(Trigger.Submit);
machine.TryFire(Trigger.Pay).Should().BeFalse();
machine.Current.Should().Be(State.Pending);
}
[Fact]
public void Pay_AllowedWhenGuardPasses()
{
var machine = new OrderMachine { HasSufficientBalance = true };
machine.TryFire(Trigger.Submit);
machine.TryFire(Trigger.Pay).Should().BeTrue();
machine.Current.Should().Be(State.Done);
}
}
Testing entry and exit hooks
Hooks are partial void methods. Override them in a test subclass or use a counter/flag to observe invocations.
public partial class TrackingOrderMachine : OrderMachine
{
public int PendingEnterCount { get; private set; }
public State? LastExitFrom { get; private set; }
partial void OnEnterPending(State from) => PendingEnterCount++;
partial void OnExitIdle(Trigger on) => LastExitFrom = State.Idle;
}
public class HookTests
{
[Fact]
public void OnEnterPending_CalledOnSubmit()
{
var machine = new TrackingOrderMachine();
machine.TryFire(Trigger.Submit);
machine.PendingEnterCount.Should().Be(1);
machine.LastExitFrom.Should().Be(State.Idle);
}
[Fact]
public void InvalidTrigger_DoesNotFireHooks()
{
var machine = new TrackingOrderMachine();
machine.TryFire(Trigger.Pay); // invalid from Idle
machine.PendingEnterCount.Should().Be(0);
machine.LastExitFrom.Should().BeNull();
}
}
Alternatively, use a simple list-based recorder without subclassing:
public partial class OrderMachine
{
public List<string> Events { get; } = new();
partial void OnExitIdle(Trigger on) => Events.Add($"ExitIdle:{on}");
partial void OnEnterPending(State from) => Events.Add($"EnterPending:{from}");
partial void OnEnterDone(State from) => Events.Add($"EnterDone:{from}");
}
[Fact]
public void FullFlow_RecordsExpectedEvents()
{
var machine = new OrderMachine();
machine.TryFire(Trigger.Submit);
machine.TryFire(Trigger.Pay);
machine.Events.Should().Equal(
"ExitIdle:Submit",
"EnterPending:Idle",
"ExitPending:Pay",
"EnterDone:Pending");
}
Testing concurrent machines
Concurrent machines are safe to call from multiple threads. Test thread safety by firing transitions from many threads simultaneously and asserting the final state is consistent:
[StateMachine(InitialState = nameof(WorkerState.Idle), Concurrent = true)]
[Transition<WorkerState, WorkerTrigger>(From = WorkerState.Idle, On = WorkerTrigger.Start, To = WorkerState.Running)]
[Transition<WorkerState, WorkerTrigger>(From = WorkerState.Running, On = WorkerTrigger.Stop, To = WorkerState.Idle)]
public partial class WorkerMachine { }
[Fact]
public void ConcurrentStart_OnlyOneSucceeds()
{
var machine = new WorkerMachine();
var results = new ConcurrentBag<bool>();
Parallel.For(0, 100, _ => results.Add(machine.TryFire(WorkerTrigger.Start)));
results.Count(r => r).Should().Be(1); // only one thread wins the CAS
machine.Current.Should().Be(WorkerState.Running);
}
[Fact]
public void ConcurrentStartStop_MachineRemainsConsistent()
{
var machine = new WorkerMachine();
var iterations = 10_000;
Parallel.For(0, iterations, i =>
{
machine.TryFire(WorkerTrigger.Start);
machine.TryFire(WorkerTrigger.Stop);
});
// State should be Idle or Running — never a corrupt value
machine.Current.Should().BeOneOf(WorkerState.Idle, WorkerState.Running);
}
Generator snapshot tests
If you maintain a source generator test project, use Verify with Microsoft.CodeAnalysis.Testing to snapshot-test the generated output. See the existing snapshot tests in tests/ZeroAlloc.StateMachine.Generator.Tests/ for the pattern.
Tips
- One machine instance per test — machines are stateful. Sharing a single instance across tests causes flaky ordering dependencies.
- Test invalid transitions explicitly — returning
falsefromTryFireis not the same as throwing. Assert both the return value andCurrent. - Don't mock
TryFire— the switch expression is the contract. Test against real instances, not mocks. - Use
nameofwhen constructing trigger/state values in tests for refactor safety.