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 15-20 lines of configureDomain boilerplate 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 --
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 command 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. configureDomain
| Scenario | Use |
|---|---|
| Testing handler logic in isolation | testAggregate, testProjection, testSaga |
| Testing multiple components together | testDomain |
| Custom persistence strategies | configureDomain from @noddde/engine |
| Custom bus implementations | configureDomain from @noddde/engine |
| Production-like environment tests | configureDomain from @noddde/engine |
Next Steps
- Testing Aggregates and Projections -- Unit-level aggregate and projection testing
- Testing Sagas -- Unit-level saga testing
- Domain Configuration -- Full
configureDomainreference