Testing your handlers
Fluxzero comes with a flexible, expressive testing framework based on the given-when-then pattern. This enables writing behavioral tests for your handlers without needing to mock the infrastructure.
Here’s a basic example:
TestFixture testFixture = TestFixture.create(new UserEventHandler());
@Testvoid newUserGetsWelcomeEmail() { testFixture.whenEvent(new CreateUser(userId, myUserProfile)) .expectCommands(new SendWelcomeEmail(myUserProfile));}val testFixture = TestFixture.create(UserEventHandler())
@Testfun newUserGetsWelcomeEmail() { testFixture.whenEvent(CreateUser(userId, myUserProfile)) .expectCommands(SendWelcomeEmail(myUserProfile))}This test ensures that when a CreateUser event occurs, a SendWelcomeEmail command is issued by the handler.
Testing complete workflows
Section titled “Testing complete workflows”You can test full workflows across multiple handlers:
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”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)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.
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.
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)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"))© 2026 Fluxzero