Skip to content

Testing queries

QueryScenario selects one decorated @query method on a read model and performs it through the real query pipeline.

The Tasks sample registers two tasks and asks for the first page, sorted by title:

import { SortDirection } from '@cratis/arc.core';
import { given } from '@cratis/arc.testing';
import { TaskId } from '../../../TaskId.js';
import { TaskTitle } from '../../../TaskTitle.js';
import { a_task_listing } from '../given/a_task_listing.js';
describe('when performing the tasks query with sorting and paging', given(a_task_listing, context => {
let result: Awaited<ReturnType<typeof context.query.perform>>;
beforeAll(() => {
context.tasks.register(TaskId.create(), new TaskTitle('Zebra'));
context.tasks.register(TaskId.create(), new TaskTitle('Apple'));
});
beforeEach(async () => {
result = await context.query.perform({}, { sorting: { field: 'title', direction: SortDirection.Ascending }, paging: { page: 0, pageSize: 1 } });
});
afterAll(async () => { await context.query.dispose(); await context.observable.dispose(); });
it('should return the first sorted wire value', () => {
result.isSuccess.should.equal(true);
String(result.data?.[0]?.title).should.equal('Apple');
});
it('should report the total number of items', () => { result.paging.totalItems.should.equal(2); });
}));

Its context creates the scenarios:

Features/Tasks/Listing/for_TaskItem/given/a_task_listing.ts
import { ObservableQueryScenario, QueryScenario } from '@cratis/arc.testing';
import { Tasks } from '../../../Tasks.js';
import { TaskItem } from '../../Listing.js';
import { metadata } from '../../../../generatedMetadata.js';
export class a_task_listing {
tasks = new Tasks();
query = QueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'allTasks');
observable = ObservableQueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'observeAllTasks');
constructor() {
this.query.extend(builder => builder.useGeneratedMetadata(metadata));
this.observable.extend(builder => builder.useGeneratedMetadata(metadata));
this.query.services.addSingleton(Tasks, this.tasks);
this.observable.services.addSingleton(Tasks, this.tasks);
}
}
  • QueryScenario.for<T>(ReadModel, 'methodName', ...artifacts) selects the query. T describes the wire data, not a rehydrated read-model instance: data is JSON-shaped, so concepts arrive as strings.
  • perform(arguments, options?) takes named arguments, such as { id: TaskId.create() }, and optional { paging, sorting }.
  • The result is the actual QueryResult. Queries always use Warning as the allowed severity.
  • withContext({ principal, tenantId }) sets a trusted identity, as for commands.

ChronicleQueryScenario runs a snapshot query through the same Arc query pipeline, with tenant-scoped in-memory Chronicle state. A query can inject ChronicleReadModels and call getById or findInstanceById to read a seeded source. Register the query’s event, reducer, and read-model types as artifacts:

Features/Accounts/for_AccountBalance/when_querying/with_seeded_history.ts
import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle/events';
import { reducer } from '@cratis/chronicle/reducers';
import { readModel as chronicleReadModel } from '@cratis/chronicle/readModels';
import { argument, query, readModel, service } from '@cratis/arc.core';
import { ChronicleReadModels } from '@cratis/arc.chronicle';
import { ChronicleQueryScenario } from '@cratis/arc.chronicle/testing';
@eventType() class BalanceChanged { @field(Number) amount: number; constructor(amount = 0) { this.amount = amount; } }
@chronicleReadModel() class AccountBalance { @field(Number) amount = 0; }
@reducer('account-balance-query-reducer', undefined, AccountBalance)
class BalanceReducer {
balanceChanged(event: BalanceChanged, current?: AccountBalance): AccountBalance {
return { amount: (current?.amount ?? 0) + event.amount };
}
}
@readModel() class AccountQueries {
@query(argument('id', String), service(ChronicleReadModels))
static byId(id: string, models: ChronicleReadModels): Promise<AccountBalance | null> {
return models.getById(AccountBalance, id);
}
}
const scenario = ChronicleQueryScenario.for<{ amount: number }>(AccountQueries, 'byId',
AccountBalance, BalanceChanged, BalanceReducer);
scenario.given.forEventSource('account-1').events(new BalanceChanged(25));
const result = await scenario.perform({ id: 'account-1' });
result.data!.amount.should.equal(25);
await scenario.dispose();

given.forEventSource(id, tenant?).readModel(instance) pins a value for that source, read-model type, and tenant; pins win over reducer or projection history. givenReadModel(Type, id, instance, tenant?) can also pin by explicit type. With no pin and no seeded history for that source, or no projection or reducer candidates for that model, a keyed lookup returns null. The tenant defaults to the current trusted scenario.context.tenantId (or Default) when .events() or .readModel() is called; withContext({ tenantId }) selects the tenant used by the query. Only keyed lookups are backed in memory. getAll and store watches reject with a kernel hint. Declared observable queries cannot be performed by ChronicleQueryScenario: observation needs ChronicleKernelScenario because ObservableQueryScenario has no in-memory Chronicle wiring.

Reducer seeding needs @cratis/chronicle 6.14.0 or later. With SDK 6.19.0 or later, the scenario evaluates supported flat projections in-process from seeded events. On older SDKs, a seeded lookup with a registered projection that omits its read-model type fails explicitly with @cratis/chronicle >= 6.19.0 and suggests @projection('', ReadModel) because Arc cannot determine the association without the evaluator. Unsupported definitions throw the SDK’s UnsupportedProjectionOperation; use ChronicleKernelScenario for those, for observation, or for observer-driven updates after a command append. See Testing Chronicle commands for the same seeding rules.

QueryScenario.perform() rejects decorated @query({ observable: true }) methods instead of returning a current snapshot as ArcServer.performQuery() does. For non-Chronicle observable queries, use ObservableQueryScenario. ChronicleQueryScenario.perform() also rejects declared observable queries and directs you to ChronicleKernelScenario for observation.

A method declared as a snapshot that unexpectedly returns a subscribable or async iterable is rejected by the core query pipeline. Whether called over HTTP or through QueryScenario, Arc attempts release through the first hook on the returned object, in order: Symbol.asyncDispose, Symbol.dispose, dispose(), close(), or return() if the object itself has both next() and return(). Arc never releases an instance owned by its service container; its scope or registry handles disposal. It never subscribes or calls [Symbol.asyncIterator]() or [Symbol.iterator]() during cleanup; a cold RxJS Observable or shared Subject with no release hook is rejected but left untouched. Cleanup has its own deadline even when the request is canceled. A release failure (including a timeout) is reported alongside the snapshot rejection; a failure after the deadline is logged. Custom scenario harnesses can use snapshotStreamSource(result) from @cratis/arc.core to inspect the rejected source; Arc has already attempted release if the source owns a hook, so do not release it a second time.