Skip to content

[#729] Add Query Testing Support To AxonTestFixture#3944

Open
theoema wants to merge 3 commits intoAxonIQ:mainfrom
theoema:feature/add-query-to-test-fixture
Open

[#729] Add Query Testing Support To AxonTestFixture#3944
theoema wants to merge 3 commits intoAxonIQ:mainfrom
theoema:feature/add-query-to-test-fixture

Conversation

@theoema
Copy link
Copy Markdown
Contributor

@theoema theoema commented Nov 20, 2025

Add Query Testing Support to AxonTestFixture

Overview

This PR adds query testing capabilities to AxonTestFixture, enabling developers to test queries
with the same fluent given-when-then API used for command testing. The implementation includes a
deterministic auto-await mechanism that ensures read models are fully caught up before queries
execute to ensure results are reliable without putting the eventual consistency inside the developers head.

Problem Statement

Previously, testing queries in Axon Framework 5 required:

  • Testing readmodels directly by using assertions.
  • Arbitrary delays with (Thread.sleep) or manual awaits to wait for async event processing
  • No fluent API for query assertions
  • Unreliable tests that could fail due to timing issues if done incorrectly.

Solution

🎯 Deterministic Token-Based Auto-Await

The key innovation is a deterministic waiting mechanism that eliminates timing-related test failures:

fixture.given()
       .events(new StudentNameChangedEvent(studentId, "John Doe", 1))
.when()
       .query(new GetStudentById(studentId))  // ← Automatically waits for processors to catch up before executing
.then()
       .expectQueryResult(expectedResult);

How it works:

  1. Before executing the query, fixture waits for all StreamingEventProcessor instances
  2. Compares each processor's current tracking token against EventStore.latestToken()
  3. Polls every 50ms until all processors reach the head position (using existing polling patterns)
  4. Only then executes the query, ensuring read model is up-to-date
  5. Default timeout: 5 seconds (configurable, or use Duration.ZERO to skip entirely)

📋 Complete API

Query Execution

.query(payload)                    // Execute with sensible default of 5-second timeout
.query(payload, Duration.ZERO)    // Execute immediately (no wait)
.query(payload, Duration.ofSeconds(10))  // Custom timeout (probably not needed but exists anyway)

Result Assertions

.then()
  .expectResult(expected)                    // Exact match
  .expectQueryResult(expected)               // Wrapper around ExpectResult to make tests more explicit and readable.
  .expectResultSatisfies(result -> { ... })  // Custom assertions
  .expectQueryResultSatisfies(result -> { ... })  // Also an Alias around expectResultSatisfies for explicitness and readability.
  .success()                                 // Assert no exception

Exception Assertions

.then()
  .exceptionSatisfies(ex -> {
      assertThat(ex).hasCauseInstanceOf(StudentNotFoundException.class);
      assertThat(ex.getCause()).hasMessage("Student not found");
  })

Note: Query handler exceptions are wrapped in QueryExecutionException, so use .hasCauseInstanceOf() to
check the actual exception.

Example Usage

@Test
void queryReturnsUpdatedReadModelAfterEventProcessing() {
    var configurer = EventSourcingConfigurer.create()
        .componentRegistry(cr -> cr.registerComponent(
            StudentRepository.class,
            cfg -> new InMemoryStudentRepository()
        ))
        .registerQueryHandlingModule(queryHandlerModule)
        .modelling(modelling -> modelling.messaging(messaging ->
            messaging.eventProcessing(ep ->
                ep.pooledStreaming(ps -> ps.processor(projectionProcessor))
            )
        ));

    var fixture = AxonTestFixture.with(configurer);

    fixture.given()
           .events(new StudentNameChangedEvent("123", "Alice", 1))
    .when()
           .query(new GetStudentById("123"))
    .then()
           .expectQueryResultSatisfies(result -> {
               GetStudentById.Result wrapped = (GetStudentById.Result) result;
               assertThat(wrapped.student().name()).isEqualTo("Alice");
           });
}

Architecture

New Components

RecordingQueryBus

Query bus decorator for testing that records all dispatched queries and their responses.

  • Location: org.axonframework.test.fixture
  • Records query-response pairs for future assertions
  • Provides recorded(), recordedQueries(), responsesOf() methods
  • reset() clears recorded queries between test phases

EventProcessorUtils

Utility class for waiting on event processors during testing.

  • Location: org.axonframework.test.util
  • Method: waitForEventProcessorsToCatchUp(AxonConfiguration, Duration)
  • Reusable for future use cases beyond queries
  • Any test code needing to wait for event processors can use this utility

AxonTestThenQuery

Implements the Then.Query phase with result and exception assertions.

  • Extends AxonTestThenMessage for event/command assertions
  • Accepts RecordingQueryBus for future query assertion features
  • Uses PayloadMatcher for deep equality checking
  • Handles wrapped exceptions (checks .getCause())

AxonTestWhen (Enhanced)

  • Added .query() methods to When phase
  • Integrated auto-await before query execution
  • Null-check for missing query handlers
  • Passes RecordingQueryBus to Then phase

Sample Domain

Comprehensive test domain in test/fixture/sampledomain:

  • StudentReadModel - Read model record
  • GetStudentById - Query with nested Result wrapper
  • StudentRepository + InMemoryStudentRepository - Repository pattern with
    thread-safe implementation
  • StudentProjection - Event handler with @EventHandler
  • StudentQueryHandler - Query handler with @QueryHandler
  • StudentNotFoundException - Custom exception

Test Coverage

Six comprehensive tests demonstrate all features:

Test Scenario
givenEventsWhenQueryThenExpectResult_Success Events → async processing → query returns result
givenEventsWhenQueryThenExpectResultSatisfies_Success Custom assertions on result structure
givenNoEventsWhenQueryThenExpectException_Success Query throws exception (checks wrapped cause)
givenNoEventsWhenQueryThenExpectExceptionWithMessage_Success Exception with specific message
givenNoEventsWhenQueryThenExpectExceptionSatisfies_Success Custom exception assertions
givenEventsWhenQueryThenSuccess_Success Success assertion (no exception)

All tests use PooledStreamingEventProcessor with async processing to verify the deterministic mechanism.

Breaking Changes

None. This is a pure addition to the existing AxonTestFixture API.

Migration Guide

No migration needed. Existing tests continue to work unchanged. New query testing is opt-in.

Related Issues

#729

Commits

  • 48e8731 Implement deterministic query testing with token-based auto-await mechanism
  • e90b19c Implement auto-await mechanism for query testing with delay-based approach
  • d3558ea Add query testing support to AxonTestFixture

Extends the AxonTestFixture with the ability to test query handlers using the same fluent API as command and event testing. This enables developers to verify query handling behavior in their test scenarios.

Key additions:
- RecordingQueryBus: Decorator that records all dispatched queries and their responses
- Query When phase: New query() method to dispatch queries during test execution
- Query Then phase: Assertion methods for validating query results (expectResult, expectResultSatisfies, success)
- MessagesRecordingConfigurationEnhancer: Extended to register the RecordingQueryBus alongside existing recording components

The implementation follows the established pattern of RecordingCommandBus and RecordingEventSink, providing consistent testing experience across all message types.
…roach

Updates the query testing feature to automatically wait for asynchronous event processors
before executing queries. This ensures the read model has been updated with all published
events from the given() phase.

Implementation:
- Default 200ms delay before query execution to allow async event processing
- Configurable delay via query(payload, Duration) overload
- Duration.ZERO option to skip waiting for synchronous processors
- Added expectQueryResult() and expectQueryResultSatisfies() convenience methods

Test coverage:
- Basic query testing with async event processors
- Wrapped result objects (e.g., Result<T> patterns)
- Null result handling
- Custom assertions on query results

The delay-based approach provides a pragmatic solution that works reliably across
different event processor types and test scenarios. Future improvements may add
deterministic tracking using StreamingEventProcessor.processingStatus().isCaughtUp()
once in-memory event store behavior is better understood.
…hanism

This commit adds comprehensive query testing support to AxonTestFixture with a
deterministic mechanism for waiting on asynchronous event processors before
executing queries.

## Key Features

### Deterministic Auto-Await Mechanism
The query testing now uses a token-based approach to ensure read models are
up-to-date before queries execute:

1. When `.query()` is called, the fixture automatically waits for all streaming
   event processors to catch up with the event store's head position
2. Uses `EventStore.latestToken()` to get the head tracking token
3. Polls each processor's `processingStatus()` every 50ms
4. Checks if each processor's current token covers/reaches the head token
5. Only executes the query once all processors have caught up
6. Default timeout of 5 seconds (configurable via `.query(payload, timeout)`)
7. Use `Duration.ZERO` to skip waiting for synchronous processors

This eliminates the need for arbitrary delays and makes tests deterministic
regardless of system load or event processing speed.

### Query Testing API
- `.query(payload)` - Execute query with automatic 5-second timeout
- `.query(payload, timeout)` - Execute query with custom timeout
- `.then().expectResult(expected)` - Assert exact query result
- `.then().expectQueryResult(expected)` - Alias for result assertion
- `.then().expectResultSatisfies(consumer)` - Custom result assertions
- `.then().expectQueryResultSatisfies(consumer)` - Alias for custom assertions
- `.then().success()` - Assert no exceptions were thrown
- `.then().exception(type)` - Assert exception type (checks wrapped cause)
- `.then().exceptionSatisfies(consumer)` - Custom exception assertions

### Exception Handling
Query handler exceptions are wrapped in `QueryExecutionException` by the
framework. Tests check the cause using `.hasCauseInstanceOf()` to verify
the actual exception thrown by the query handler.

### Architecture Changes

**EventProcessorUtils (new)**
- Extracted event processor waiting logic into reusable utility class
- Located in `org.axonframework.test.util` package
- Public static method `waitForEventProcessorsToCatchUp(config, timeout)`
- Can be reused by other test utilities in the future

**AxonTestWhen**
- Added `.query()` methods to When phase
- Integrated auto-await mechanism before query execution
- Added null-check for query responses to handle missing handlers
- Passes `RecordingQueryBus` to Then phase

**AxonTestThenQuery (new)**
- Implements Then.Query phase with result and exception assertions
- Accepts `RecordingQueryBus` for potential future query assertions
- Extends `AxonTestThenMessage` for event/command assertion support
- Uses `PayloadMatcher` for deep equality checking

### Sample Domain for Testing

Created comprehensive sample domain in `test/fixture/sampledomain`:

**Domain Model**
- `StudentReadModel` - Read model record
- `GetStudentById` - Query with nested `Result` wrapper
- `StudentRepository` - Repository interface with `findByIdOrThrow()`
- `InMemoryStudentRepository` - Thread-safe in-memory implementation
- `StudentNotFoundException` - Custom exception for missing students

**Event Handling**
- `StudentProjection` - Annotated event handler with `@EventHandler`
- Processes `StudentNameChangedEvent` to update read model

**Query Handling**
- `StudentQueryHandler` - Annotated query handler with `@QueryHandler`
- Returns wrapped `GetStudentById.Result`
- Throws `StudentNotFoundException` when student not found

### Test Coverage

Six comprehensive tests demonstrate all features:

1. `givenEventsWhenQueryThenExpectResult_Success`
   - Events published → read model updated → query returns wrapped result

2. `givenEventsWhenQueryThenExpectResultSatisfies_Success`
   - Custom assertions on query result structure

3. `givenNoEventsWhenQueryThenExpectException_Success`
   - Query with no data throws expected exception (checks cause)

4. `givenNoEventsWhenQueryThenExpectExceptionWithMessage_Success`
   - Exception with specific message verification

5. `givenNoEventsWhenQueryThenExpectExceptionSatisfies_Success`
   - Custom exception assertions with message content checks

6. `givenEventsWhenQueryThenSuccess_Success`
   - Success assertion when no exceptions occur

All tests use `PooledStreamingEventProcessor` with async event processing to
verify the deterministic auto-await mechanism works correctly.
@theoema theoema requested a review from a team as a code owner November 20, 2025 23:04
@theoema theoema requested review from abuijze, hatzlj and smcvb and removed request for a team November 20, 2025 23:04
@theoema
Copy link
Copy Markdown
Contributor Author

theoema commented Nov 23, 2025

Hey @abuijze @smcvb @MateuszNaKodach @zambrovski! Does this resemble the direction you wanted to go for query testing? I was thinking of adding subscriptionQuery testing aswell but wanted to check in with you before proceeding.

@theoema theoema changed the title Add Query Testing Support To AxonTestFixture [#729] Add Query Testing Support To AxonTestFixture Nov 26, 2025
@smcvb smcvb added Type: Feature Use to signal an issue is completely new to the project. Priority 2: Should High priority. Ideally, these issues are part of the release they’re assigned to. labels Nov 26, 2025
@smcvb smcvb modified the milestones: Release 5.0.1, Release 5.1.0 Nov 26, 2025
Copy link
Copy Markdown
Contributor

@smcvb smcvb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a rough skim of what you've provided, @theoema. Rough, mainly, because the AF-team still wants (and needs) to discuss what the API is going to be.

Although there's nothing wrong with providing a new API to us in a PR, the test fixture flow is already quite different from what it was in AF2, AF3, and AF4 with the current setup.

Next, the issue you attached it too, called projection testing, is not strictly scoping in query handler validation. It suggests we have a means to validate "the activities" performed by an event handler (and if not, I need to update the description). Again, not wrong to add queries in the mix, as we want verification for those as well.

Long story short, we're not suited yet to approve or request changes on this PR. Once we've had more discussions on the angle to take for projection testing and query handler validation, we'll be sure to update this PR too.

MessageStream<QueryResponseMessage> responseStream = delegate.query(query, context);

// Collect responses for recording using reduce
List<QueryResponseMessage> responses = responseStream.reduce(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the returned MessageStream is based on a Publisher/Flux, I think it will become an issue to collect everything.

* @param expectedResult The expected query result.
* @return The current Then instance, for fluent interfacing.
*/
Query expectResult(@Nonnull Object expectedResult);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering whether the expect operations should be a reflection of the event and command versions. Query responses can be singular, plural, directly returned, async, or reactive. Whether that should be reflected is up for debate still. Differently put, we have not started the design sessions for this in the AF-team.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this is the main issue to discuss here - how to support ALL our query types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smcvb @MateuszNaKodach I hear you. I could do some research on how to better support reactive MessageStreams. As for the supporting of ALL query types I guess it would be beneficial to break it down what we would like the API to look like for different query types. For example I think it makes a lot of sense to have the API for subscriptionQueries to be:

given()
.subscriptionQuery(query)
.when()
.events(events)
.then()
.expect(initialResult(result)
.and()
.expectUpdate(update).

Which is completely different from the api in this PR, so I think it would be hard to group ALL query types under one single API. I would assume that the best course of action is to put this on hold until you guys have agreed on the future of this API. I'm sure some of the stuff in this PR is still usable either way.

@smcvb smcvb added the Status: Under Discussion Use to signal that the issue in question is being discussed. label Nov 26, 2025
@smcvb
Copy link
Copy Markdown
Contributor

smcvb commented Mar 17, 2026

Setting this PR to milestone 5.2.0, as we do not have sufficient time to round of this contribution before our foreseen release date of 5.1.0.

@smcvb smcvb modified the milestones: Release 5.1.0, Release 5.2.0 Mar 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority 2: Should High priority. Ideally, these issues are part of the release they’re assigned to. Status: Under Discussion Use to signal that the issue in question is being discussed. Type: Feature Use to signal an issue is completely new to the project.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants