Testing Overview
How noddde's functional design and @noddde/testing toolkit make testing natural and straightforward.
noddde's functional architecture is built for testability. Command handlers are functions. Apply handlers are pure functions. Projection reducers are pure functions. Saga handlers return data. There is no framework to boot, no container to configure, and no decorators to mock.
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 | Command handlers + apply | 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 | Apply 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 configureDomain 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 apply handlers to reconstruct the pre-command state, then executes the command 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
You can always call handlers directly without any harness. The aggregate, projection, and saga objects are plain JavaScript objects with function properties:
// Call a command handler directly
const events = BankAccount.commands.AuthorizeTransaction(
command,
state,
infrastructure,
);
// Call an apply handler directly
const newState = BankAccount.apply.TransactionAuthorized(payload, state);
// Call a projection reducer directly
const newView = BankProjection.reducers.TransactionAuthorized(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