Test a command
Goal: verify the command’s result and its actual effect without pretending that a successful result proves a database write.
Use the standalone harness
Section titled “Use the standalone harness”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.
Define the service-backed example
Section titled “Define the service-backed example”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();}public interface AuthorRegistration { void register(AuthorId id, AuthorName name);}
@Commandpublic record RecordAuthor(AuthorId id, AuthorName name) { public void handle(AuthorRegistration registration) { registration.register(id, name); }}
public final class RecordAuthorValidator implements BlockingCommandValidator<RecordAuthor> { @Override public Class<RecordAuthor> getCommandType() { return RecordAuthor.class; }
@Override public List<ValidationResult> validate(RecordAuthor command, CommandContext context) { return command.name().value().isBlank() ? List.of(ValidationResult.error("An author needs a name.", List.of("name"))) : List.of(); }}
// A Java validator is an ordinary class; the adapter is what Arc discovers as a bean.@Configurationpublic class RecordAuthorValidation { @Bean public CommandValidator<RecordAuthor> recordAuthorValidator() { return new BlockingCommandValidatorAdapter<>(new RecordAuthorValidator()); }}package library.authors
import io.cratis.arc.artifacts.Commandimport io.cratis.arc.commands.CommandContextimport io.cratis.arc.commands.CommandValidatorimport io.cratis.arc.results.ValidationResultimport org.springframework.stereotype.Component
interface AuthorRegistration { suspend fun register(id: AuthorId, name: AuthorName)}
@Commanddata class RecordAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(registration: AuthorRegistration) { registration.register(id, name) }}
@Componentclass RecordAuthorValidator : CommandValidator<RecordAuthor> { override val commandType: Class<RecordAuthor> = RecordAuthor::class.java
override suspend fun validate( command: RecordAuthor, context: CommandContext ): List<ValidationResult> = if (command.name.value().isBlank()) { listOf(ValidationResult.error("An author needs a name.", listOf("name"))) } else { emptyList() }}import { field } from '@cratis/fundamentals';import { command, CommandValidator, inject, validator } from '@cratis/arc.core';import { AuthorId } from './AuthorId.js';import { AuthorName } from './AuthorName.js';
export abstract class AuthorRegistration { abstract register(id: AuthorId, name: AuthorName): Promise<void>;}
@command()export class RecordAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@inject(AuthorRegistration) handle(registration: AuthorRegistration): Promise<void> { return registration.register(this.id, this.name); }}
@validator(RecordAuthor)export class RecordAuthorValidator extends CommandValidator<RecordAuthor> { constructor() { super(); this.ruleFor(command => command.name).notEmpty(); }}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.
Assert the effect through the pipeline
Section titled “Assert the effect through the pipeline”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();}import io.cratis.arc.generated.LibraryArcArtifactModule;import io.cratis.arc.testing.CommandScenario;import io.cratis.arc.testing.java.BlockingCommandScenario;import java.util.ArrayList;import java.util.List;import java.util.UUID;import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class WhenRecordingAnAuthor { @Test void recordsTheAuthor() { var id = new AuthorId(UUID.randomUUID()); var name = new AuthorName("Ada Lovelace"); var recorded = new ArrayList<AuthorId>(); AuthorRegistration registration = (registeredId, registeredName) -> recorded.add(registeredId);
var scenario = new CommandScenario<>(new LibraryArcArtifactModule(), RecordAuthor.class) .addService(AuthorRegistration.class, registration);
// BlockingCommandScenario owns a bounded dispatcher, so a JUnit test needs no coroutine. try (var blocking = new BlockingCommandScenario<>(scenario)) { blocking.execute(new RecordAuthor(id, name)).shouldSucceed(); }
assertEquals(List.of(id), recorded); }}package library.specs
import io.cratis.arc.generated.LibraryArcArtifactModuleimport io.cratis.arc.testing.CommandScenarioimport kotlinx.coroutines.runBlockingimport library.authors.AuthorIdimport library.authors.AuthorNameimport library.authors.AuthorRegistrationimport library.authors.RecordAuthorimport java.util.UUIDimport kotlin.test.assertEqualsimport org.junit.jupiter.api.Test
class WhenRecordingAnAuthor { @Test fun `records the author`() = runBlocking { val id = AuthorId(UUID.randomUUID()) val name = AuthorName("Ada Lovelace") val recorded = mutableListOf<Pair<AuthorId, AuthorName>>() val registration = object : AuthorRegistration { override suspend fun register(id: AuthorId, name: AuthorName) { recorded += id to name } }
val result = CommandScenario(LibraryArcArtifactModule(), RecordAuthor::class.java) .addService(AuthorRegistration::class.java, registration) .execute(RecordAuthor(id, name))
result.shouldSucceed() assertEquals(listOf(id to name), recorded) }}import { CommandScenario, type ScenarioCommandResult } from '@cratis/arc.testing';import { deepStrictEqual } from 'node:assert';import { afterEach, beforeEach, describe, it } from 'vitest';import { AuthorId } from './AuthorId.js';import { AuthorName } from './AuthorName.js';import { AuthorRegistration, RecordAuthor, RecordAuthorValidator } from './RecordAuthor.js';
describe('when recording an author', () => { let scenario: CommandScenario<RecordAuthor>; let result: ScenarioCommandResult; let registered: string[][]; let id: AuthorId; let name: AuthorName;
beforeEach(async () => { registered = []; id = AuthorId.create(); name = new AuthorName('Ada Lovelace'); scenario = CommandScenario.for(RecordAuthor, RecordAuthorValidator); scenario.services.addSingleton(AuthorRegistration, { register: async (authorId: AuthorId, authorName: AuthorName) => { registered.push([authorId.toString(), authorName.value]); } }); result = await scenario.execute({ id, name }); });
afterEach(async () => { await scenario.dispose(); });
it('should succeed through the real command pipeline', () => { result.shouldBeSuccessful(); });
it('should register the author with the intended values', () => { deepStrictEqual(registered, [[id.toString(), name.value]]); });});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.
Check rejection independently
Section titled “Check rejection independently”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.
Optional Chronicle testing
Section titled “Optional Chronicle testing”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.