Testing Overview
How noddde's functional design and @noddde/testing toolkit make testing natural and straightforward.
noddde's functional architecture is built for testability. Decide handlers are functions. Evolve handlers are pure functions. Projection reducers are pure functions. Saga handlers return data. Tests call the handlers directly with sample inputs and assert on the events or state they return.
The @noddde/testing package provides type-safe test harnesses that eliminate boilerplate and express tests in the natural Given-When-Then pattern of the Decider.
Install
npm install --save-dev @noddde/testing
# or
yarn add --dev @noddde/testingThree Testing Levels
Level 1: Unit Tests (Handler Isolation)
Test individual handlers without booting a domain. The harnesses replay events, call handlers, and return structured results.
import { testAggregate } from "@noddde/testing";
const result = await testAggregate(BankAccount)
.given(
{ name: "AccountCreated", payload: { id: "acc-1" } },
{ name: "DepositMade", payload: { amount: 500 } },
)
.when({
name: "AuthorizeTransaction",
targetAggregateId: "acc-1",
payload: { amount: 200 },
})
.execute();
expect(result.events[0].name).toBe("TransactionAuthorized");
expect(result.state.availableBalance).toBe(300);Available unit harnesses:
| Harness | Tests | Pattern |
|---|---|---|
testAggregate | Decide handlers + evolve | Given events, When command, Then events + state |
testProjection | Projection reducers | Given events, Then view |
testSaga | Saga event handlers | Given state, When event, Then state + commands |
evolveAggregate | evolve handlers only | Replay events to reconstruct state |
Level 2: Slice Tests (Pre-Wired Domain)
Test multiple components together with zero infrastructure boilerplate. testDomain automatically wires in-memory buses, persistence, and installs spies.
import { testDomain } from "@noddde/testing";
const { domain, spy } = await testDomain({
aggregates: { BankAccount },
projections: { BankAccountView },
sagas: { OrderFulfillment },
});
await domain.dispatchCommand({
name: "CreateBankAccount",
targetAggregateId: "acc-1",
});
expect(spy.publishedEvents).toContainEqual({
name: "BankAccountCreated",
payload: { id: "acc-1" },
});Level 3: Integration Tests
Use defineDomain and wireDomain from @noddde/engine directly when you need full control over persistence, custom buses, or production-like wiring.
The Given-When-Then Pattern
The Decider pattern maps naturally to Given-When-Then:
| Concept | Given | When | Then |
|---|---|---|---|
| Aggregate | Prior events (replayed through apply) | Command | Produced events + new state |
| Projection | Prior events (reduced through reducers) | -- | Final view |
| Saga | Prior saga state | Incoming event | New state + dispatched commands |
For aggregates, "Given" means "these events already happened." The harness replays them through the aggregate's evolve handlers to reconstruct the pre-command state, then executes the decide handler.
For sagas, this pattern is inverted: events come in, commands go out.
Error Testing
All unit harnesses capture errors in the result instead of throwing. This makes testing rejection cases straightforward:
const result = await testAggregate(BankAccount)
.when({
name: "AuthorizeTransaction",
targetAggregateId: "acc-1",
payload: { amount: 999999 },
})
.execute();
expect(result.error).toBeDefined();
expect(result.error!.message).toContain("insufficient");
expect(result.events).toEqual([]);Direct Handler Testing
Since all handlers are extracted to standalone functions, you can import and test them directly without any harness:
// Decide handler — import the extracted function
import { decideAuthorizeTransaction } from "./deciders/decide-authorize-transaction";
const events = decideAuthorizeTransaction(command, state, infrastructure);
// evolve handler — import the extracted function
import { evolveTransactionAuthorized } from "./evolvers/evolve-transaction-authorized";
const newState = evolveTransactionAuthorized(payload, state);
// Projection event handler — import the extracted on-entry
import { onTransactionAuthorized } from "./on-entries/on-transaction-authorized";
const newView = onTransactionAuthorized.reduce(event, view);The harnesses add value by managing state reconstruction from events, normalizing results, and capturing errors -- but they are optional.
Infrastructure Stubs for Testing
Infrastructure injection is what makes noddde handlers testable. Because infrastructure is passed as function parameters (not via DI containers), you can substitute test implementations trivially.
The most common pattern is the Clock: inject a Clock interface instead of calling new Date() directly, then use a FixedClock in tests for deterministic behavior. This pattern generalizes to any non-deterministic dependency -- random ID generators, external APIs, or file storage.
For infrastructure with multiple members, create a reusable mock factory:
function createMockBankingInfrastructure() {
return {
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
bankAccountViewRepository: {
getById: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
},
};
}Then pass it via .withInfrastructure() in unit harnesses or the infrastructure field in testDomain.
Event Metadata Testing Utilities
When events flow through the Domain, they are automatically enriched with an event metadata envelope containing audit, tracing, and sequencing information. The @noddde/testing package provides utilities for asserting on metadata and for stripping it when you only care about event payloads.
Stripping Metadata for Payload Assertions
Most tests care about what happened (event name + payload), not when or by whom. Use stripMetadata to remove the metadata envelope:
import { stripMetadata } from "@noddde/testing";
const { spy } = await testDomain({ aggregates: { BankAccount } });
await domain.dispatchCommand({
name: "CreateBankAccount",
targetAggregateId: "acc-1",
});
expect(stripMetadata(spy.publishedEvents)).toContainEqual({
name: "BankAccountCreated",
payload: { id: "acc-1" },
});Validating Metadata
Use expectValidMetadata to verify that an event carries a well-formed metadata envelope with all required fields:
import { expectValidMetadata } from "@noddde/testing";
// Asserts: eventId is UUID v7, timestamp is valid ISO 8601,
// correlationId and causationId are non-empty strings
expectValidMetadata(spy.publishedEvents[0]!);Correlation and Causation Assertions
Two helpers verify that events form a proper tracing chain:
import { expectSameCorrelation, expectCausationChain } from "@noddde/testing";
// All events share the same correlationId (same causal chain)
expectSameCorrelation(spy.publishedEvents);
// Each event's causationId equals the previous event's eventId
expectCausationChain(spy.publishedEvents);These are particularly useful when testing saga workflows, where correlation should propagate across aggregate boundaries.
Deterministic Metadata for Exact Assertions
For tests that need exact equality (snapshot tests, deterministic replay), use createTestMetadataFactory to produce predictable metadata:
import { createTestMetadataFactory } from "@noddde/testing";
const metadataFactory = createTestMetadataFactory({
correlationId: "test-corr-id",
userId: "test-user",
// eventIdGenerator defaults to sequential "evt-1", "evt-2", ...
// timestampGenerator defaults to fixed "2024-01-01T00:00:00.000Z"
});
const metadata = metadataFactory({
aggregateName: "BankAccount",
aggregateId: "acc-1",
sequenceNumber: 1,
});
expect(metadata.eventId).toBe("evt-1");
expect(metadata.correlationId).toBe("test-corr-id");Summary of Metadata Utilities
| Utility | Purpose |
|---|---|
stripMetadata(events) | Remove metadata for payload-only assertions |
expectValidMetadata(event) | Assert metadata has valid eventId, timestamp, correlationId, causationId |
expectSameCorrelation(events) | Assert all events share the same correlationId |
expectCausationChain(events) | Assert events form an eventId→causationId chain |
createTestMetadataFactory() | Create a deterministic metadata factory for exact assertions |
Next Steps
- Testing Aggregates and Projections --
testAggregate,evolveAggregate, andtestProjectionin depth - Testing Sagas --
testSagafor workflow testing - Testing Domains --
testDomainfor slice and integration tests