noddde

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/testing

Three 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:

HarnessTestsPattern
testAggregateCommand handlers + applyGiven events, When command, Then events + state
testProjectionProjection reducersGiven events, Then view
testSagaSaga event handlersGiven state, When event, Then state + commands
evolveAggregateApply handlers onlyReplay 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:

ConceptGivenWhenThen
AggregatePrior events (replayed through apply)CommandProduced events + new state
ProjectionPrior events (reduced through reducers)--Final view
SagaPrior saga stateIncoming eventNew 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

UtilityPurpose
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

On this page