Testing Sagas
Using testSaga for Given-When-Then saga testing with automatic CQRS infrastructure.
Sagas orchestrate workflows across aggregates by reacting to events and dispatching commands. The testSaga harness from @noddde/testing tests saga handlers in isolation with the inverse Decider pattern: event in, commands out.
testSaga
import { testSaga } from "@noddde/testing";
const result = await testSaga(OrderFulfillmentSaga)
.givenState({ status: "awaiting_payment", orderId: "o-1" })
.when({
name: "PaymentReceived",
payload: { orderId: "o-1", paymentId: "p-1" },
})
.execute();
expect(result.state).toEqual({
status: "fulfilled",
orderId: "o-1",
});
expect(result.commands).toEqual([
{
name: "FulfillOrder",
targetAggregateId: "o-1",
},
]);Builder API
| Method | Purpose |
|---|---|
.givenState(state) | Saga state before the event. Defaults to saga.initialState if not called. |
.when(event) | The event to process through the saga handler. Required before .execute(). |
.withInfrastructure(infra) | Custom infrastructure. Defaults to {}. |
.withCQRSInfrastructure(cqrs) | Override the default no-op CQRS buses. |
.execute() | Runs the test and returns the result. Always async. |
Result Shape
type SagaTestResult<TState, TCommands> = {
state: TState; // New saga state
commands: TCommands[]; // Commands to dispatch (always an array, empty if none)
error?: Error; // Captured error (if handler threw)
};Commands are always normalized to an array, even when the handler returns a single command or undefined.
Testing Saga Startup
When no .givenState() is called, the handler receives saga.initialState:
const result = await testSaga(OrderFulfillmentSaga)
.when({
name: "OrderPlaced",
payload: { orderId: "o-1", amount: 99.99 },
})
.execute();
expect(result.state.status).toBe("awaiting_payment");
expect(result.commands).toEqual([
{
name: "RequestPayment",
targetAggregateId: "o-1",
payload: { orderId: "o-1", amount: 99.99 },
},
]);Testing State-Only Reactions
Some saga handlers update state without dispatching commands:
const result = await testSaga(OrderFulfillmentSaga)
.givenState({ status: "fulfilled", orderId: "o-1" })
.when({
name: "OrderFulfilled",
payload: { orderId: "o-1" },
})
.execute();
expect(result.commands).toEqual([]);
expect(result.state.status).toBe("fulfilled");Automatic CQRS Infrastructure
Saga handlers receive TInfrastructure & CQRSInfrastructure -- they have access to commandBus, eventBus, and queryBus. The harness automatically provides no-op implementations so you do not need to wire mock buses for most tests.
Most saga handlers return commands in the reaction rather than dispatching through the bus directly. The no-op buses satisfy the type contract without affecting test behavior.
Custom Infrastructure
When saga handlers use domain-specific infrastructure (notification services, APIs, etc.), provide it via .withInfrastructure():
const mockNotifier = { send: vi.fn().mockResolvedValue(undefined) };
const result = await testSaga(NotificationSaga)
.when({
name: "ShipmentDelivered",
payload: { orderId: "o-1", trackingNumber: "TRK-123" },
})
.withInfrastructure({ notificationService: mockNotifier })
.execute();
expect(mockNotifier.send).toHaveBeenCalledWith(
expect.stringContaining("TRK-123"),
);Overriding CQRS Infrastructure
In rare cases where a saga handler calls commandBus.dispatch directly (instead of returning commands in the reaction), override the CQRS buses:
const mockDispatch = vi.fn().mockResolvedValue(undefined);
const result = await testSaga(SpecialSaga)
.when(someEvent)
.withCQRSInfrastructure({
commandBus: { dispatch: mockDispatch },
})
.execute();
expect(mockDispatch).toHaveBeenCalled();Error Handling
If a saga handler throws, the error is captured:
const result = await testSaga(StrictSaga)
.givenState({ validated: false })
.when({ name: "InvalidEvent", payload: { bad: true } })
.execute();
expect(result.error).toBeDefined();
expect(result.commands).toEqual([]);Testing Associations
Saga associations (the functions that extract a saga ID from an event) are plain functions. Test them directly:
const sagaId = OrderFulfillmentSaga.associations.OrderPlaced({
name: "OrderPlaced",
payload: { orderId: "o-1", amount: 99.99 },
});
expect(sagaId).toBe("o-1");Each bounded context may name the correlation field differently (orderId, referenceId, customerReference), so verify that each association resolves to the same saga ID:
describe("associations", () => {
it("should extract orderId from OrderPlaced", () => {
const event = {
name: "OrderPlaced" as const,
payload: {
orderId: "order-1",
customerId: "c-1",
items: [],
total: 0,
placedAt: new Date(),
},
};
expect(OrderFulfillmentSaga.associations.OrderPlaced(event)).toBe(
"order-1",
);
});
it("should extract referenceId from PaymentCompleted", () => {
const event = {
name: "PaymentCompleted" as const,
payload: {
paymentId: "pay-1",
referenceId: "order-1",
amount: 100,
completedAt: new Date(),
},
};
expect(OrderFulfillmentSaga.associations.PaymentCompleted(event)).toBe(
"order-1",
);
});
});Testing Full Workflows
Replay a sequence of events through the saga to verify the complete lifecycle. You can either use the harness for each step or call handlers directly:
it("should complete the full happy path", async () => {
let state = OrderFulfillmentSaga.initialState;
let commands: any;
// Step 1: Order placed
({ state, commands } = OrderFulfillmentSaga.handlers.OrderPlaced(
{
name: "OrderPlaced",
payload: {
orderId: "o-1",
customerId: "c-1",
items: [],
total: 100,
placedAt: new Date(),
},
},
state,
mockInfra,
));
expect(state.status).toBe("awaiting_payment");
expect(commands.name).toBe("RequestPayment");
// Step 2: Payment completed
({ state, commands } = OrderFulfillmentSaga.handlers.PaymentCompleted(
{
name: "PaymentCompleted",
payload: {
paymentId: "p-1",
referenceId: "o-1",
amount: 100,
completedAt: new Date(),
},
},
state,
mockInfra,
));
expect(state.status).toBe("awaiting_shipment");
// Step 3: Shipment dispatched
({ state, commands } = OrderFulfillmentSaga.handlers.ShipmentDispatched(
{
name: "ShipmentDispatched",
payload: {
shipmentId: "s-1",
customerReference: "o-1",
trackingNumber: "TRK-123",
dispatchedAt: new Date(),
},
},
state,
mockInfra,
));
expect(state.status).toBe("shipped");
expect(state.trackingNumber).toBe("TRK-123");
});Next Steps
- Testing Aggregates and Projections --
testAggregatefor command handler testing - Testing Domains --
testDomainfor full saga orchestration tests - Defining Sagas -- Saga definition reference