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<TInfrastructure extends Infrastructure = Infrastructure> =
{
/** Aggregate definitions keyed by name. */
aggregates?: Record<string, Aggregate<any>>;
/** Projection definitions keyed by name. */
projections?: Record<string, Projection<any>>;
/** Optional per-projection ViewStoreFactory singletons. */
projectionViewStores?: Record<string, { viewStore: ViewStoreFactory }>;
/** Saga definitions keyed by name. */
sagas?: Record<string, Saga<any, any>>;
/** Optional standalone query handlers keyed by query name. */
standaloneQueryHandlers?: Record<string, any>;
/** Optional custom infrastructure to provide to handlers. */
infrastructure?: TInfrastructure;
};Only specify what you are testing. Everything else is wired automatically:
- Command bus --
InMemoryCommandBuswith spy - Event bus --
EventEmitterEventBuswith 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
| Scenario | Use |
|---|---|
| Testing handler logic in isolation | testAggregate, testProjection, testSaga |
| Testing multiple components together | testDomain |
| Custom persistence strategies | defineDomain from @noddde/core + wireDomain from @noddde/engine |
| Custom bus implementations | defineDomain from @noddde/core + wireDomain from @noddde/engine |
| Production-like environment tests | defineDomain from @noddde/core + wireDomain from @noddde/engine |
Next Steps
- Domain Configuration -- Full
defineDomain+wireDomainreference for production wiring - Persistence Adapters -- Swap the in-memory test adapter for a real database in production