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() + "!"; }}
@Testvoid greetsTheCaller() { TestFixture.create(new GreetingHandler()) .whenQuery(new GetGreeting("Alice")) .expectResult("Hello, Alice!");}data class GetGreeting(val name: String) : Request<String>
class GreetingHandler { @HandleQuery fun handle(query: GetGreeting) = "Hello, ${query.name}!"}
@Testfun greetsTheCaller() { TestFixture.create(GreetingHandler()) .whenQuery(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.
Testing complete workflows
Section titled “Testing complete workflows”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());
@Testvoid creatingUserTriggersEmail() { fixture.whenCommand(new CreateUser(userProfile)) .expectCommands(new SendWelcomeEmail(userProfile));}val fixture = TestFixture.create(UserCommandHandler(), UserEventHandler())
@Testfun creatingUserTriggersEmail() { fixture.whenCommand(CreateUser(userProfile)) .expectCommands(SendWelcomeEmail(userProfile))}fixture.whenCommand(new CreateUser(userProfile)) .expectOnlyCommands(new SendWelcomeEmail(userProfile));fixture.whenCommand(CreateUser(userProfile)) .expectOnlyCommands(SendWelcomeEmail(userProfile))You can also match by class, predicate, or Hamcrest matcher:
fixture.whenCommand(new CreateUser(userProfile)) .expectCommands( SendWelcomeEmail.class, isA(AddUserToOrganization.class) );fixture.whenCommand(CreateUser(userProfile)) .expectCommands( SendWelcomeEmail::class.java, isA(AddUserToOrganization::class.java) )Chained expectations
Section titled “Chained expectations”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(...));fixture.whenCommand(CreateUser(userProfile)) .expectCommands(SendWelcomeEmail(userProfile)) .expectEvents(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);fixture.whenCommand(CreateUser(userProfile)) .expectCommands(SendWelcomeEmail(userProfile)) .andThen() .whenQuery(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.
Using givenXxx() for preconditions
Section titled “Using givenXxx() for preconditions”Use givenCommands, givenEvents, etc., to simulate preconditions:
fixture.givenCommands(new CreateUser(userProfile), new ResetPassword(...)) .whenCommand(new UpdatePassword(...)) .expectEvents(UpdatePassword.class);fixture.givenCommands(CreateUser(userProfile), ResetPassword(...)) .whenCommand(UpdatePassword(...)) .expectEvents(UpdatePassword::class.java)Providing external JSON files
Section titled “Providing external JSON files”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");fixture.givenCommands("create-user.json") .whenQuery(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");fixture.givenCommands("/users/create-user.json")Class resolution with @class
Section titled “Class resolution with @class”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 TypeRegistryMarkerThe 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).
Testing older revisions with @revision
Section titled “Testing older revisions with @revision”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.
Inheriting from other JSON files
Section titled “Inheriting from other JSON files”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.
Adding or asserting metadata
Section titled “Adding or asserting metadata”Wrap your payload in a Message to attach or validate metadata:
@Testvoid newAdminGetsAdditionalEmail() { testFixture.whenCommand(new Message(new CreateUser(...), Metadata.of("roles", Arrays.asList("Customer", "Admin")))) .expectCommands(new SendWelcomeEmail(...), new SendAdminEmail(...));}@Testfun newAdminGetsAdditionalEmail() { testFixture.whenCommand( Message(CreateUser(...), Metadata.of("roles", listOf("Customer", "Admin"))) ).expectCommands( SendWelcomeEmail(...), SendAdminEmail(...) )}Result and exception assertions
Section titled “Result and exception assertions”You can assert the result returned by a command or query:
fixture.givenCommands(new CreateUser(userProfile)) .whenQuery(new GetUser(userId)) .expectResult(userProfile);fixture.givenCommands(CreateUser(userProfile)) .whenQuery(GetUser(userId)) .expectResult(userProfile)To assert that an exception occurred:
fixture.givenCommands(new CreateUser(userProfile)) .whenCommand(new CreateUser(userProfile)) .expectExceptionalResult(IllegalCommandException.class);fixture.givenCommands(CreateUser(userProfile)) .whenCommand(CreateUser(userProfile)) .expectExceptionalResult(IllegalCommandException::class.java)User-aware tests
Section titled “User-aware tests”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);val user = MyUser("pete")
fixture.whenCommandByUser(user, "confirm-user.json") .expectExceptionalResult(UnauthorizedException::class.java)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);fixture .givenCommands("create-user-pete.json") .whenCommandByUser("pete", "confirm-user.json") .expectExceptionalResult(UnauthorizedException::class.java)Verifying side effects
Section titled “Verifying side effects”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(...));fixture.whenCommand("create-user-pete.json") .expectThat { Mockito.verify(emailService).sendEmail(...) }Triggering side effects manually
Section titled “Triggering side effects manually”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");fixture.whenExecuting { httpClient.put("/user", "/users/user-profile-pete.json") } .expectEvents("create-user-pete.json")Asynchronous tests
Section titled “Asynchronous tests”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);val fixture = TestFixture.createAsync(MyHandler(), MyStatefulHandler::class.java)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 thewhen...()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.
Using test fixtures in Spring
Section titled “Using test fixtures in Spring”Fluxzero integrates seamlessly with Spring Boot. You can inject a TestFixture directly:
@SpringBootTestclass AsyncAppTest {
@Autowired TestFixture fixture;
@Test void testSomething() { fixture.whenCommand("commands/my-command.json") .expectEvents("events/expected-event.json"); }}@SpringBootTestclass AsyncAppTest {
@Autowired lateinit var fixture: TestFixture
@Test fun testSomething() { fixture.whenCommand("commands/my-command.json") .expectEvents("events/expected-event.json") }}@Import(FluxzeroTestConfig.class)@Import(FluxzeroTestConfig::class)Switching to synchronous mode
Section titled “Switching to synchronous mode”By default, the injected fixture is asynchronous. To switch to synchronous mode:
Globally via application.properties:
fluxzero.test.sync=trueOr per test class:
@TestPropertySource(properties = "fluxzero.test.sync=true")@SpringBootTestclass SyncAppTest {
@Autowired TestFixture fixture;
// test logic...}@TestPropertySource(properties = ["fluxzero.test.sync=true"])@SpringBootTestclass SyncAppTest {
@Autowired lateinit var fixture: TestFixture
// test logic...}Testing schedules
Section titled “Testing schedules”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());
@Testvoid accountIsTerminatedAfterClosing() { testFixture .givenCommands(new CreateUser(myUserProfile), new CloseAccount(userId)) .whenTimeElapses(Duration.ofDays(30)) .expectEvents(new AccountTerminated(userId));}val testFixture = TestFixture.create(UserCommandHandler(), UserLifecycleHandler())
@Testfun accountIsTerminatedAfterClosing() { testFixture .givenCommands(CreateUser(myUserProfile), CloseAccount(userId)) .whenTimeElapses(Duration.ofDays(30)) .expectEvents(AccountTerminated(userId))}In this test:
CloseAccountschedules anAccountTerminatedevent.whenTimeElapses(Duration.ofDays(30))simulates 30 days passing.- The test then checks that the event was published.
You can also test cancellation logic:
@Testvoid accountReopeningCancelsTermination() { testFixture .givenCommands(new CreateUser(myUserProfile), new CloseAccount(userId), new ReopenAccount(userId)) .whenTimeElapses(Duration.ofDays(30)) .expectNoEventsLike(AccountTerminated.class);}@Testfun accountReopeningCancelsTermination() { testFixture .givenCommands(CreateUser(myUserProfile), CloseAccount(userId), ReopenAccount(userId)) .whenTimeElapses(Duration.ofDays(30)) .expectNoEventsLike(AccountTerminated::class.java)}If needed, you can also advance time to a fixed timestamp:
fixture.whenTimeAdvancesTo(Instant.parse("2050-12-31T00:00:00Z"));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