Skip to content

Command scenarios

CommandScenario<TCommand> exercises Arc’s real command pipeline. Use it when a direct Handle() call would miss validation, authorization, dependency resolution, or execution scopes. It also executes returned command operations and their eligible compensation with the services you register; it is not a recording-only simulator. Follow testing operations for a complete failure/recovery lesson. It complements fast decision specs; it is not a requirement for every command test. Start with the decision-and-pipeline lesson, or use Testing to choose the right boundary. This page is the scenario API reference.

Use Cratis.Arc.Testing for standalone Arc tests, plus Cratis.Specifications.XUnit when writing Specifications. Chronicle is not required. The optional Cratis.Testing meta-package also brings Chronicle support; it is not the minimal standalone choice.

Import Cratis.Arc.Testing.Commands for both the scenario and result assertion extensions.

MemberContract
ServicesIServiceCollection for registrations made before initialization
ContextIDictionary<string, object> populated by extenders
Execute(TCommand command)Returns Task<CommandResult>; runs filters, argument resolution (including Provide()), the handler, response processing, and execution scopes
Execute(TCommand command, CancellationToken cancellationToken)Runs the same real pipeline with explicit forward cancellation; compensation uses its own cleanup token
LastResultThe result from the latest Execute, including backend-only recovery observations
OperationsSnapshot of started operation invocations from the latest Execute; empty before execution
Validate(TCommand command)Returns Task<CommandResult>; runs pipeline filters but skips handler argument resolution, Provide(), Handle(), and execution scopes
Dispose() / DisposeAsync()Releases the provider and disposable context values

There is no typed Execute<TResult> overload on the scenario. For a known successful response, cast to CommandResult<TResponse> and inspect its public Response, as in the standalone checkpoint. The pipeline constructs that generic result from the returned value’s runtime type; do not assume a cast to a base/interface response type will work. Failure or no-response results need not have that generic type.

Instantiate the scenario in your spec. At construction it creates Services and Context, configures options and logging without a sink, then discovers and invokes ICommandScenarioExtender implementations. Extenders need a public parameterless constructor. Optional packages can therefore change the scenario’s services without a different base class.

The first Execute or Validate call builds the service provider and resolves ICommandPipeline. Before that call, register application services, substitutes, and options through Services, usually in Establish(). Later registrations do not rebuild the provider. Discovered validators are constructed on demand using the command scope; you do not need to manually register every validator.

This runs pipeline behavior, not everything in your production host. There is no HTTP routing, request binding, authentication middleware, or automatic copy of your host’s registrations. Application services may still access external infrastructure unless you replace them. Use host/integration tests for those boundaries.

To opt into console logging while debugging, use this setup fragment before the first pipeline call (with Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.Logging imported and the console logging package available):

_scenario.Services.AddLogging(logging => logging.AddConsole());

This complete test file uses the packages from the standalone quick start. It defines the command and validator so the message assertion has a known source. Compile it separately from the quick-start file, or give the types distinct names.

using System.Threading.Tasks;
using Cratis.Arc.Commands;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Testing.Commands;
using Cratis.Specifications;
using FluentValidation;
using Xunit;
namespace ValidationSpecs;
[Command]
public record NormalizeName(string Name)
{
public string Handle() => Name.Trim();
}
public class NormalizeNameValidator : CommandValidator<NormalizeName>
{
public NormalizeNameValidator()
{
RuleFor(command => command.Name)
.NotEmpty()
.WithMessage("Name is required");
}
}
public class when_validating_an_empty_name : Specification
{
readonly CommandScenario<NormalizeName> _scenario = new();
CommandResult _result = default!;
async Task Because() =>
_result = await _scenario.Validate(new NormalizeName(string.Empty));
[Fact] void should_have_validation_errors() =>
_result.ShouldHaveValidationErrors();
[Fact] void should_report_the_required_name() =>
_result.ShouldHaveValidationErrorFor("Name is required");
void Destroy() => _scenario.Dispose();
}

Run dotnet test; both facts should pass. Validate still requires a discoverable command handler, but does not call it. A successful validation result does not prove that Provide(), Handle(), or a commit-time check will succeed during execution. Custom filters can also perform work, so validation is not a universal side-effect-free sandbox.

These extension methods return void. Built-in assertion failures throw CommandResultAssertionException. After its own check passes, every helper below applies discovered ICommandResultAssertionPolicy implementations through CommandResultAssertionPolicies; a policy can still fail the assertion or throw its own exception.

MethodBuilt-in check
ShouldBeSuccessful()IsSuccess is true; includes failure reasons otherwise
ShouldNotBeSuccessful()IsSuccess is false
ShouldBeValid()IsValid is true
ShouldHaveValidationErrors()IsValid is false, and the validation results are not exclusively DependencyUnavailable
ShouldHaveValidationErrorFor(string message)A validation result’s message contains the text using ordinal, case-sensitive matching; this is not a property-path lookup
ShouldHaveValidationErrorBecauseOf(ValidationResultReason reason)A validation result carries that reason
ShouldHaveConstraintViolationFor(string constraintName)A validation result has reason ConstraintViolation and ReasonDetail exactly equal to the name
ShouldBeAuthorized()IsAuthorized is true
ShouldNotBeAuthorized()IsAuthorized is false
ShouldNotHaveExceptions()HasExceptions is false
ShouldHaveExceptions()HasExceptions is true

A broad failure assertion can pass for the wrong reason. Pair it with a specific message, reason, or constraint assertion. Message matching fails if the message is reworded to remove the expected text; it does not silently become a no-op. For optional Chronicle constraints, prefer ShouldHaveConstraintViolationFor with your application’s constraint-name constant instead of relying on prose.

These assertions are available directly on CommandScenario<TCommand> and as extension methods on CommandResult through Cratis.Arc.Testing.Commands:

MethodCheck
ShouldHaveExecutedOperation<TOperation>()At least one invocation of that exact operation type returned successfully from Execute().
ShouldHaveCompensatedOperation<TOperation>()At least one invocation of that exact type returned successfully from Compensate().
ShouldHaveNoOperationInvocations()No operation entered Execute(), including invocations that partially executed and threw.

Scenario assertions require a completed Execute() call; they do not pass vacuously before execution. LastResult and Operations describe the latest execution, not the latest Validate() call. When several operations have the same type, use the per-invocation observations and provider assertions to prove counts, arguments, and order; the type-based helpers assert at least one match, not every instance.

An invocation that throws can appear in Operations with ExecutionCompleted false and still have completed compensation. ShouldHaveExecutedOperation intentionally does not count that partial invocation as successful execution. These helpers throw CommandResultAssertionException on mismatch; they inspect operation observations rather than applying the general command-result assertion policy chain described above.

See testing operations, failure cases, and Chronicle commit rejection for complete examples.

Dependency unavailable is not a business-rule rejection

Section titled “Dependency unavailable is not a business-rule rejection”

A missing required read model can reject a command before its validator is constructed. ShouldHaveValidationErrors() deliberately fails when all validation results have reason DependencyUnavailable: otherwise a spec might pass without the business rule ever running. If other validation reasons are present too, the broad assertion can pass; it still does not identify which rule ran.

Seed/register the required state when testing a business rule. When unavailable state is itself the intended outcome, assert the reason explicitly. This is an assertion fragment for a spec with _result already assigned; import Cratis.Arc.Validation:

[Fact] void should_reject_unavailable_state() =>
_result.ShouldHaveValidationErrorBecauseOf(ValidationResultReason.DependencyUnavailable);

This distinguishes a registered provider returning missing required state or an unusable key from an unregistered required service, which can instead produce an exception outcome. See ARC0006 for the resolution distinction and the optional Chronicle seeding helpers for event-sourced tests.

Pipeline authorization reads ICurrentPrincipalAccessor. Test its behavior by supplying a principal; this does not test login or token validation. The following complete standalone test file additionally requires NSubstitute:

using System.Security.Claims;
using System.Threading.Tasks;
using Cratis.Arc.Authorization;
using Cratis.Arc.Commands;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Testing.Commands;
using Cratis.Specifications;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Xunit;
namespace AuthorizationSpecs;
public enum ApplicationRole { Administrator, User }
[Command]
[Roles(nameof(ApplicationRole.Administrator))]
public record RunAdministration()
{
public string Handle() => "Completed";
}
public class when_a_regular_user_runs_administration : Specification
{
readonly CommandScenario<RunAdministration> _scenario = new();
CommandResult _result = default!;
void Establish()
{
var principalAccessor = Substitute.For<ICurrentPrincipalAccessor>();
principalAccessor.Current.Returns(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, nameof(ApplicationRole.User))],
authenticationType: "test")));
_scenario.Services.AddSingleton(principalAccessor);
}
async Task Because() =>
_result = await _scenario.Execute(new RunAdministration());
[Fact] void should_not_be_authorized() =>
_result.ShouldNotBeAuthorized();
void Destroy() => _scenario.Dispose();
}

Run dotnet test; the fact should pass because the authenticated user lacks the required role. Arc pipeline authorization and host-level policy/scheme enforcement are distinct boundaries.

The scenario owns its provider and disposable values in Context. Keep a field-initialized scenario readonly when the spec does not replace it; a field assigned or replaced in Establish() can remain mutable. Specification already implements xUnit’s IAsyncLifetime and calls Destroy() by convention for cleanup. Use void Destroy() => _scenario.Dispose();; no additional disposal interface is needed on the spec class.

With plain xUnit, use the test framework’s supported disposal lifecycle. Asynchronous scenario disposal prefers IAsyncDisposable on owned values and falls back to IDisposable.

Disposal is idempotent. Calling Execute or Validate afterwards throws ObjectDisposedException. Dispose per scenario to avoid accumulating providers during long test runs.