noddde

Testing Domains

Using testDomain for zero-boilerplate slice and integration tests with built-in spies.

The testDomain harness from @noddde/testing creates a fully wired domain with in-memory implementations and spy instrumentation. It eliminates the boilerplate of defineDomain + wireDomain that every integration test otherwise requires.

testDomain

import { testDomain } from "@noddde/testing";

const { domain, spy } = await testDomain({
  aggregates: { BankAccount },
  projections: { BankAccountView },
});

await domain.dispatchCommand({
  name: "CreateBankAccount",
  targetAggregateId: "acc-1",
});

await domain.dispatchCommand({
  name: "AuthorizeTransaction",
  targetAggregateId: "acc-1",
  payload: { amount: 100, merchant: "Store" },
});

// Assert on events that flowed through the system
expect(spy.publishedEvents).toHaveLength(2);
expect(spy.publishedEvents[0].name).toBe("BankAccountCreated");
expect(spy.publishedEvents[1].name).toBe("TransactionAuthorized");

// Assert on projection views via the view store
const view = await bankAccountViewStore.load("acc-1");
expect(view?.balance).toBe(-100);

Configuration

type TestDomainConfig = {
  aggregates?: Record<string, Aggregate>; // Aggregate definitions
  projections?: Record<string, Projection>; // Projection definitions
  sagas?: Record<string, Saga>; // Saga definitions (optional)
  infrastructure?: Infrastructure; // Custom infrastructure (optional)
};

Only specify what you are testing. Everything else is wired automatically:

  • Command bus -- InMemoryCommandBus with spy
  • Event bus -- EventEmitterEventBus with spy
  • Query bus -- InMemoryQueryBus
  • Aggregate persistence -- InMemoryEventSourcedAggregatePersistence
  • Saga persistence -- InMemorySagaPersistence (only when sagas are configured)

Result Shape

type TestDomainResult = {
  domain: Domain; // The fully initialized domain
  spy: DomainSpy; // Spy accessors
};

type DomainSpy = {
  publishedEvents: Event[]; // All events dispatched via the event bus
  dispatchedCommands: Command[]; // All commands dispatched via the command bus
};

Spy: Published Events

spy.publishedEvents records every event published on the event bus, in order. This captures events from aggregate decide handlers after persistence:

const { domain, spy } = await testDomain({
  aggregates: { BankAccount },
});

await domain.dispatchCommand({
  name: "CreateBankAccount",
  targetAggregateId: "acc-1",
});

await domain.dispatchCommand({
  name: "DepositMade",
  targetAggregateId: "acc-1",
  payload: { amount: 500 },
});

expect(spy.publishedEvents).toEqual([
  { name: "BankAccountCreated", payload: expect.any(Object) },
  { name: "DepositMade", payload: { amount: 500 } },
]);

Spy: Dispatched Commands

spy.dispatchedCommands records commands dispatched through the command bus. This is most useful for verifying saga behavior -- sagas dispatch commands as reactions to events:

const { domain, spy } = await testDomain({
  aggregates: {},
  sagas: { OrderFulfillment },
});

// Trigger the saga via the event bus
await domain.infrastructure.eventBus.dispatch({
  name: "OrderPlaced",
  payload: { orderId: "o-1", amount: 99.99 },
});

expect(spy.dispatchedCommands).toContainEqual({
  name: "RequestPayment",
  targetAggregateId: "o-1",
  payload: { orderId: "o-1", amount: 99.99 },
});

Commands dispatched by sagas that have no registered handler are captured by the spy but do not throw. This lets you test saga orchestration without wiring every downstream aggregate.

Testing Projection Views

After dispatching commands, check the projection view directly:

const { domain } = await testDomain({
  aggregates: { BankAccount },
  projections: { BankAccountView },
});

await domain.dispatchCommand({
  name: "CreateBankAccount",
  targetAggregateId: "acc-1",
});

await domain.dispatchCommand({
  name: "DepositMade",
  targetAggregateId: "acc-1",
  payload: { amount: 1000 },
});

const view = await bankAccountViewStore.load("acc-1");
expect(view?.balance).toBe(1000);

Custom Infrastructure

Pass domain-specific dependencies that handlers need:

const mockLogger = { info: vi.fn(), error: vi.fn() };

const { domain } = await testDomain<BankingInfrastructure>({
  aggregates: { BankAccount },
  infrastructure: {
    logger: mockLogger,
    bankAccountViewRepository: { getById: vi.fn() } as any,
  },
});

await domain.dispatchCommand({
  name: "CreateBankAccount",
  targetAggregateId: "acc-1",
});

expect(mockLogger.info).toHaveBeenCalled();

Full Slice Test Example

Testing the complete order fulfillment flow -- aggregate, projection, and saga together:

const { domain, spy } = await testDomain({
  aggregates: { Order, Payment },
  projections: { OrderSummary },
  sagas: { OrderFulfillment },
});

// Place an order
await domain.dispatchCommand({
  name: "PlaceOrder",
  targetAggregateId: "o-1",
  payload: {
    customerId: "c-1",
    items: [{ sku: "WIDGET", qty: 2 }],
    total: 49.99,
  },
});

// Saga should have dispatched a payment command
expect(spy.dispatchedCommands).toContainEqual(
  expect.objectContaining({ name: "RequestPayment" }),
);

// Complete the payment
await domain.dispatchCommand({
  name: "CompletePayment",
  targetAggregateId: "o-1",
  payload: { transactionId: "txn-1" },
});

// Verify the projection shows the order status via view store
const summary = await orderSummaryViewStore.load("o-1");
expect(summary?.status).toBe("confirmed");

When to Use testDomain vs. defineDomain + wireDomain

ScenarioUse
Testing handler logic in isolationtestAggregate, testProjection, testSaga
Testing multiple components togethertestDomain
Custom persistence strategiesdefineDomain + wireDomain from @noddde/engine
Custom bus implementationsdefineDomain + wireDomain from @noddde/engine
Production-like environment testsdefineDomain + wireDomain from @noddde/engine

Next Steps

On this page