Skip to main content

Custom State

A XceptoState is a single step in the state machine. It has two entry points that serve distinct roles:

MethodWhen calledPurpose
onEnter / OnEnterOnce, when the machine enters this stateOne-time actions (commands, mutations)
evaluateConditionsForTransition / EvaluateConditionsForTransitionRepeatedly, until it returns trueConditions (expectations, readiness checks)

Action state (execute once)

Use this for commands that should run exactly once and fail immediately if they don't succeed:

public sealed class SendCommandState : XceptoState
{
private readonly DomainClient _client;
private readonly string _entityId;
private readonly DomainAction _action;

public SendCommandState(string name, DomainClient client,
string entityId, DomainAction action) : base(name)
{
_client = client;
_entityId = entityId;
_action = action;
}

public override async Task OnEnter(IServiceProvider sp)
{
var result = await _client.SendCommandAsync(_entityId, _action);
if (!result.Accepted)
throw new XceptoTestFailedException(
$"Command {_action} was not accepted: {result.Reason}");
}

public override Task<bool> EvaluateConditionsForTransition(
IServiceProvider sp) => Task.FromResult(true);
}

Expectation state (retry until matched)

Use this for conditions that may not be satisfied immediately — the machine retries until they pass:

public sealed class EntityStatusState : XceptoState
{
private readonly DomainClient _client;
private readonly string _entityId;
private readonly EntityStatus _expected;

public EntityStatusState(string name, DomainClient client,
string entityId, EntityStatus expected) : base(name)
{
_client = client;
_entityId = entityId;
_expected = expected;
}

public override Task OnEnter(IServiceProvider sp) => Task.CompletedTask;

public override async Task<bool> EvaluateConditionsForTransition(
IServiceProvider sp)
{
var actual = await _client.GetStatusAsync(_entityId);
var matched = actual == _expected;
if (!matched)
MostRecentFailingResult = new ConditionResult(
actual,
$"Expected {_entityId} to reach {_expected}, got {actual}");
return matched;
}
}

MostRecentFailingResult

When evaluateConditionsForTransition returns false and the test eventually times out, the timeout error includes the state's name. Use MostRecentFailingResult (.NET) to surface diagnostic information about the last failing evaluation:

MostRecentFailingResult = new ConditionResult(
actual,
$"Expected {_entityId} to reach {_expected}, got {actual}");

This appears in the timeout exception message, making it clear why the state failed to transition — not just which state was stuck.

State name matters

The state name is the primary diagnostic output when a test times out. Use descriptive names:

// Good — explains what was expected
new EntityStatusState($"Entity {entityId} reaches {expected}", ...)

// Bad — generic, unhelpful on timeout
new EntityStatusState("Assertion", ...)

State reuse

A state class should represent a reusable evaluation shape — not a specific test case. Push test-case-specific data (entity IDs, expected values, action types) into constructor parameters and expose them through the builder.

Do not create a new state class for each test case. See Fluent Builder for how builders parameterize states.