Skip to content

Test a command

Goal: verify the command’s result and its actual effect without pretending that a successful result proves a database write.

On the JVM the equivalent harness is io.cratis:arc-testing, added as a testImplementation dependency and driven from an ordinary JUnit 5 test. TypeScript’s source preview uses @cratis/arc.testing with CommandScenario.for(...) and an in-process command pipeline. Each backend registers collaborators and asserts actual effects; setup and assertion names differ.

Reference Cratis.Arc.Testing in an xUnit spec project, together with Cratis.Specifications.XUnit, NSubstitute, and the usual xUnit runner/test SDK. Cratis.Testing is the wider integration meta-package; it is not required here. A dedicated spec project can run in Debug and Release; use #if DEBUG only when intentionally embedding specs in an application assembly that excludes them from Release.

For decision-only cases, a direct Handle() call can be the simpler test. The decision-and-pipeline lesson shows how the two approaches complement each other. This recipe deliberately tests service interaction through Arc.

CommandScenario<TCommand> runs the real validation, authorization, Provide, and handler pipeline. It does not automatically fake your Mongo collection, context, or application services. Register all collaborators before the first Execute or Validate; the service provider is built lazily then. Dispose the scenario afterward.

This example uses only the AuthorId and AuthorName concept declarations from the backend lesson, not its database-backed RegisterAuthor. Put these declarations in Library.Authors in the application project:

using Cratis.Arc.Commands;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Validation;
using FluentValidation;
namespace Library.Authors;
public interface IAuthorRegistration
{
Task Register(AuthorId id, AuthorName name);
}
[Command]
public record RecordAuthor(AuthorId Id, AuthorName Name)
{
public Task Handle(IAuthorRegistration registration) => registration.Register(Id, Name);
}
public class RecordAuthorValidator : CommandValidator<RecordAuthor>
{
public RecordAuthorValidator() => RuleFor(command => command.Name.Value).NotEmpty();
}

View C# snippet source on GitHub

IAuthorRegistration / AuthorRegistration is an application-owned interface, not an Arc API. Production supplies its implementation; the spec supplies a substitute. If you test the Mongo tutorial command instead, supply its actual collection/validator dependencies or deliberately run a database integration spec.

Create the spec in the test project:

using System.Threading.Tasks;
using Cratis.Arc.Commands;
using Cratis.Arc.Testing.Commands;
using Cratis.Specifications;
using Library.Authors;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Xunit;
namespace Library.Specs;
public class when_recording_an_author : Specification
{
readonly CommandScenario<RecordAuthor> _scenario = new();
readonly AuthorId _id = AuthorId.New();
readonly AuthorName _name = new("Ada Lovelace");
IAuthorRegistration _registration = null!;
CommandResult _result = null!;
void Establish()
{
_registration = Substitute.For<IAuthorRegistration>();
_registration.Register(_id, _name).Returns(Task.CompletedTask);
_scenario.Services.AddSingleton(_registration);
}
async Task Because() => _result = await _scenario.Execute(new RecordAuthor(_id, _name));
[Fact] void should_succeed() => _result.ShouldBeSuccessful();
[Fact] async Task should_register_the_author() => await _registration.Received(1).Register(_id, _name);
void Destroy() => _scenario.Dispose();
}

View C# snippet source on GitHub

The first assertion checks the pipeline result; the second proves the handler called its collaborator with the intended values. Neither proves a production database mapping or index — test those at their integration boundary.

The three harnesses differ in how the scenario is constructed and how it is torn down. C#‘s CommandScenario<TCommand> discovers the command from its type argument, exposes an IServiceCollection, and is disposable. Arc.Kotlin’s CommandScenario takes the KSP-generated ArcArtifactModule plus the command class — so the generated wiring itself is under test — registers collaborators one at a time with addService(type, instance), and needs no disposal; execute is a suspending function, and the result carries shouldSucceed, shouldBeInvalid, shouldBeUnauthorized, and shouldHaveResponse. There is no NSubstitute on the JVM side, so a hand-written object expression stands in for the collaborator. TypeScript’s scenario registers the collaborator in scenario.services, executes with await scenario.execute(...), and disposes with await scenario.dispose(); the fragment uses a recording fake.

In a separate spec, use the same service setup and execute the command with an empty author name. On C#, assert both ShouldNotBeSuccessful() and ShouldHaveValidationErrors(), and verify Register was not called; on the JVM, chain shouldBeAuthorized().shouldBeInvalid() so the spec pins which stage rejected the command. Validate / validate is available when you intentionally want pre-flight validation without invoking the handler; keep that action in a separate spec as well.

For protected commands, set up the principal explicitly and assert ShouldNotBeAuthorized() / shouldBeUnauthorized() separately from validation. The scenario reference and the JVM in-process testing guide cover context setup and the complete assertion API.

Only event-sourced commands need the Chronicle testing extension. It adds event/read-model seeding and append assertions. Keep those specs labeled as integration behavior; standalone Arc tests assert database/application-service effects instead.