Skip to main content

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 false from TryFire is not the same as throwing. Assert both the return value and Current.
  • Don't mock TryFire — the switch expression is the contract. Test against real instances, not mocks.
  • Use nameof when constructing trigger/state values in tests for refactor safety.