Skip to content

Testing your handlers

Test the behavior a caller can observe: a returned answer, a rejected action or a message sent to another part of the application. TestFixture runs handlers locally with the SDK’s message processing, so no database or platform mocks are needed.

For example, test this complete query handler:

record GetGreeting(String name) implements Request<String> {}
class GreetingHandler {
@HandleQuery
String handle(GetGreeting query) { return "Hello, " + query.name() + "!"; }
}
@Test
void greetsTheCaller() {
TestFixture.create(new GreetingHandler())
.whenQuery(new GetGreeting("Alice"))
.expectResult("Hello, Alice!");
}

The pattern is given → when → then. Use given... methods to establish earlier state, one when... call for the action under test, and expect... methods for its observable results. This example needs no prior state.

Register the handlers involved in a workflow to test their combined behavior. In a user-signup application, for example, UserCommandHandler creates the user and UserEventHandler reacts by requesting a welcome email:

TestFixture fixture = TestFixture.create(new UserCommandHandler(), new UserEventHandler());
@Test
void creatingUserTriggersEmail() {
fixture.whenCommand(new CreateUser(userProfile))
.expectCommands(new SendWelcomeEmail(userProfile));
}
fixture.whenCommand(new CreateUser(userProfile))
.expectOnlyCommands(new SendWelcomeEmail(userProfile));

You can also match by class, predicate, or Hamcrest matcher:

fixture.whenCommand(new CreateUser(userProfile))
.expectCommands(
SendWelcomeEmail.class,
isA(AddUserToOrganization.class)
);

Multiple expectations can be chained to test the full sequence of events and commands:

fixture.whenCommand(new CreateUser(userProfile))
.expectCommands(new SendWelcomeEmail(userProfile))
.expectEvents(new UserStatsUpdated(...));

You can also chain multiple inputs using .andThen() to simulate a sequence of events, commands, or queries:

fixture.whenCommand(new CreateUser(userProfile))
.expectCommands(new SendWelcomeEmail(userProfile))
.andThen()
.whenQuery(new GetUser(userId))
.expectResult(userProfile);

This example first triggers a CreateUser command, expects a SendWelcomeEmail command, and then issues a GetUser query, asserting that it returns the expected result.

Use givenCommands, givenEvents, etc., to simulate preconditions:

fixture.givenCommands(new CreateUser(userProfile), new ResetPassword(...))
.whenCommand(new UpdatePassword(...))
.expectEvents(UpdatePassword.class);

Test fixtures support loading inputs from external JSON resources. This allows you to keep your tests clean and reuse structured input data.

Any givenXyz(...), whenXyz(...), or expectXyz(...) method argument that is a String ending with .json will be interpreted as a classpath resource path, and deserialized accordingly.

For example:

fixture.givenCommands("create-user.json")
.whenQuery(new GetUser(userId))
.expectResult("user-profile.json");

If your test class is in the org.example package, this will resolve to /org/example/create-user.json in the classpath, unless the JSON path is absolute (starts with /), e.g.:

fixture.givenCommands("/users/create-user.json");

Each JSON file must include a @class property to enable deserialization:

{
"@class": "org.example.CreateUser",
"userId": "3290328",
"email": "foo.bar@example.com"
}

If your classes or packages are annotated with @RegisterType, you can use simple class names:

{
"@class": "CreateUser"
}

Or partial paths:

{
"@class": "example.CreateUser"
}

For Kotlin, run the annotation processor with kapt and annotate a marker type with the package root:

@RegisterType(root = "io.fluxzero.yourapp.user")
object TypeRegistryMarker

The registry is also used for messages from frontends and other external producers. Simple names are safe when unique; otherwise include enough trailing package segments to disambiguate them. Fully qualified names remain supported.

Registered exact and package type aliases apply to root and nested @class values, so existing fixtures can retain a legacy fully qualified name after a class or package move. Prefer fluxzero.serialization.typeAliases or FLUXZERO_SERIALIZATION_TYPE_ALIASES for application-wide configuration. A builder or the fixture’s registerTypeAlias(...) and registerPackageAlias(...) methods can provide programmatic test configuration.

TestFixture supplies its serializer automatically. Code that reads an untyped resource directly with JsonUtils can opt into the same behavior with JsonUtils.fromFileWithTypeMapper(referenceClass, resource, serializer::resolveTypeName).

Add a root-level @revision next to @class to represent an older serialized payload without writing a full Data wrapper:

{
"@class": "org.example.UserCreated",
"@revision": 0,
"revision": 42,
"name": "Alice"
}

@class becomes the serialized data type and @revision becomes its revision. Both markers are removed before the payload enters the upcaster chain; type aliases are applied after that chain, and the regular revision field remains part of the payload. This works in TestFixture JSON inputs and in untyped JsonUtils.fromFile(...) and JsonUtils.fromJson(...) calls. Explicitly typed JsonUtils overloads keep their declared return type.

JSON resources can extend other resources using the @extends keyword:

{
"@extends": "create-user.json",
"details": {
"lastName": "Johnson"
}
}

This will recursively merge the referenced file (/org/example/create-user.json) with the current one, allowing you to override or augment deeply nested structures.

Each object in a root or nested array resolves its own inheritance relative to the file containing it. Array containers are preserved, including single-element arrays and explicitly typed array reads. JSONL/NDJSON resources retain their independent record boundaries.


Wrap your payload in a Message to attach or validate metadata:

@Test
void newAdminGetsAdditionalEmail() {
testFixture.whenCommand(new Message(new CreateUser(...),
Metadata.of("roles", Arrays.asList("Customer", "Admin"))))
.expectCommands(new SendWelcomeEmail(...),
new SendAdminEmail(...));
}

You can assert the result returned by a command or query:

fixture.givenCommands(new CreateUser(userProfile))
.whenQuery(new GetUser(userId))
.expectResult(userProfile);

To assert that an exception occurred:

fixture.givenCommands(new CreateUser(userProfile))
.whenCommand(new CreateUser(userProfile))
.expectExceptionalResult(IllegalCommandException.class);

User-aware tests use your application’s UserProvider. The CLI starter supplies a SenderProvider and registers it in META-INF/services/io.fluxzero.sdk.tracking.handling.authentication.UserProvider, so plain fixtures can find it without starting Spring. Keep that registration when adapting the starter. Alternatively, register the provider explicitly on the fixture’s DefaultFluxzero builder. An IDP dependency or a Spring bean alone does not configure a plain fixture.

With that provider configured, you can simulate a command being issued by a specific user:

var user = new MyUser("pete");
fixture.whenCommandByUser(user, "confirm-user.json")
.expectExceptionalResult(UnauthorizedException.class);

A supplied User skips the fixture’s initial ID lookup, but still travels through the provider’s metadata handling. With ID metadata enabled, later resolution can call getUserById(...); a refreshing provider can also reload current roles. Create the corresponding application user as setup when your provider resolves users from stored profiles. Without a provider, the no-op fallback does not preserve the supplied user.

You can also pass a user ID string directly instead of a User object. The test fixture will resolve it using the configured UserProvider (by default loaded via Java’s ServiceLoader):

fixture
.givenCommands("create-user-pete.json")
.whenCommandByUser("pete", "confirm-user.json")
.expectExceptionalResult(UnauthorizedException.class);

Use expectThat() or expectTrue() to verify side effects, such as interactions with external services (e.g., using Mockito):

fixture.whenCommand("create-user-pete.json")
.expectThat(fc -> Mockito.verify(emailService).sendEmail(...));

Use whenExecuting() to test code that runs outside the message dispatch loop (e.g., HTTP calls):

fixture.whenExecuting(fc -> httpClient.put("/user", "/users/user-profile-pete.json"))
.expectEvents("create-user-pete.json");

By default, TestFixture.create(...) creates a synchronous fixture where handlers are executed in the same thread. This makes unit tests fast and deterministic.

Synchronous fixtures also check the payload and web-route selection of each tracked handler’s consumer. They use the same consumer assignment, combined handler selection and serializer/upcaster metadata as tracking. This is enabled by default, so ordinary behavior expectations also detect filters that would exclude a needed input. An original SerializedMessage input is checked using its original pre-upcast type and revision. Register casters before using historical inputs. For logical inputs the serializer describes type/revision/format without serializing the body, so filtering does not run payload getters before validation. Unknown serializer/envelope overrides and generic container payloads retain broad admission; verify their wire behavior with async tests. Explicit @LocalHandler methods and ordinary local self-handling commands/queries bypass consumer selection, just as they do in production.

The application property fluxzero.tracking.consumerFiltering=false (FLUXZERO_TRACKING_CONSUMER_FILTERING) or a per-consumer consumerFiltering(false) override disables this check. This applies to both Java and Kotlin fixtures. Synchronous execution still skips the external serialized-dispatch phase: use an asynchronous fixture to verify a custom DispatchInterceptor.modifySerializedMessage transformation, consumer retries, batching, or delivery timing. Use a connected Test Server/Runtime test for transport negotiation.

However, in production, handlers are typically dispatched asynchronously via consumers. To simulate this behavior in tests, especially for event-driven workflows or stateful consumers, you can use:

TestFixture fixture = TestFixture.createAsync(new MyHandler(), MyStatefulHandler.class);

This ensures that:

  • Handlers are tracked using real consumer infrastructure.
  • Asynchronous behavior (e.g., retries, delays, state changes) is tested realistically.
  • expect...() calls wait for outcomes, enabling end-to-end flow testing.
  • given...() preconditions complete before the when...() phase starts.

The asynchronous fixture registers newly discovered @TrackSelf payloads and automatic Model commands before concurrent dispatch proceeds. A failed registration remains a failure for that type; use a fresh fixture after correcting its configuration, rather than retrying a partially installed handler.

Fluxzero integrates seamlessly with Spring Boot. You can inject a TestFixture directly:

@SpringBootTest
class AsyncAppTest {
@Autowired
TestFixture fixture;
@Test
void testSomething() {
fixture.whenCommand("commands/my-command.json")
.expectEvents("events/expected-event.json");
}
}
@Import(FluxzeroTestConfig.class)

By default, the injected fixture is asynchronous. To switch to synchronous mode:

Globally via application.properties:

fluxzero.test.sync=true

Or per test class:

@TestPropertySource(properties = "fluxzero.test.sync=true")
@SpringBootTest
class SyncAppTest {
@Autowired
TestFixture fixture;
// test logic...
}

Fluxzero makes it easy to test time-based workflows. Scheduled messages behave like any other message, except they’re delayed until their due time.

Use TestFixture to simulate time passing:

TestFixture testFixture = TestFixture.create(new UserCommandHandler(), new UserLifecycleHandler());
@Test
void accountIsTerminatedAfterClosing() {
testFixture
.givenCommands(new CreateUser(myUserProfile),
new CloseAccount(userId))
.whenTimeElapses(Duration.ofDays(30))
.expectEvents(new AccountTerminated(userId));
}

In this test:

  • CloseAccount schedules an AccountTerminated event.
  • whenTimeElapses(Duration.ofDays(30)) simulates 30 days passing.
  • The test then checks that the event was published.

You can also test cancellation logic:

@Test
void accountReopeningCancelsTermination() {
testFixture
.givenCommands(new CreateUser(myUserProfile),
new CloseAccount(userId),
new ReopenAccount(userId))
.whenTimeElapses(Duration.ofDays(30))
.expectNoEventsLike(AccountTerminated.class);
}

If needed, you can also advance time to a fixed timestamp:

fixture.whenTimeAdvancesTo(Instant.parse("2050-12-31T00:00:00Z"));

Automatic JUnit fixture cleanup waits until owned resources are closed. It skips the response grace period for requests that the completed test left unanswered, including expected timeout scenarios. Explicit application close() calls retain their normal graceful response wait. To test a different cleanup grace period, configure fluxzero.shutdown.requestTimeoutMillis (FLUXZERO_SHUTDOWN_REQUEST_TIMEOUT_MILLIS) in the fixture’s application property source; an explicit value takes precedence. This setting does not change request deadlines or wire metadata.


© 2026 Fluxzero