Skip to content

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());
@Test
void newUserGetsWelcomeEmail() {
testFixture.whenEvent(new CreateUser(userId, myUserProfile))
.expectCommands(new SendWelcomeEmail(myUserProfile));
}

This test ensures that when a CreateUser event occurs, a SendWelcomeEmail command is issued by the handler.

You can test full workflows across multiple handlers:

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);

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);

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.

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.

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"));

© 2026 Fluxzero