noddde

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

MethodPurpose
.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

On this page