Testing Domains and Pipelines
Plexis domains and pipelines are plain async objects with no hidden global state — they are easy to test. The key testing seams are: followFrom() to start a domain from any phase without running through every prior event, history() to assert on the recorded event trail, and pipeline.run() called with context constructed to route through the fork you want to test.
Testing a domain event
Section titled “Testing a domain event”import { defineDomain, when, on, target, terminal } from '@tde.io/plexis';import { expect, test } from 'vitest';
const order = defineDomain('order', () => { when('pending', () => { on('submit', target('processing')); }); when('processing', () => { on('fulfill', target('fulfilled')); }); when('fulfilled', terminal()); return { context: { orderId: 'ord_1' }, initial: 'pending' };});
test('submit moves pending → processing', async () => { const result = await order.follow('submit');
expect(result.status).toBe('followed'); expect(result.from).toBe('pending'); expect(result.to).toBe('processing'); expect(order.phase).toBe('processing');});Using followFrom() to start at an arbitrary phase
Section titled “Using followFrom() to start at an arbitrary phase”Real domain paths often require several events to reach the phase you want to test. followFrom() teleports the domain to any registered phase before following the event — without running exit, enter, or any pipeline for the intermediate phases.
test('fulfill moves processing → fulfilled', async () => { // Start at 'processing' without going through submit const result = await order.followFrom('processing', 'fulfill');
expect(result.status).toBe('followed'); expect(result.to).toBe('fulfilled'); expect(order.phase).toBe('fulfilled');});followFrom() also resets the domain’s starting context to the current context, so tests are isolated from prior events on the same instance. Create a fresh domain per test suite if you need fully independent history.
Asserting on history()
Section titled “Asserting on history()”domain.history() returns every completed event as a DomainHistoryEntry — { from, to, event, payload, context, timestamp, traceId }.
test('history records both events', async () => { await order.follow('submit'); await order.follow('fulfill');
const history = order.history();
expect(history).toHaveLength(2); expect(history[0].event).toBe('submit'); expect(history[0].from).toBe('pending'); expect(history[1].event).toBe('fulfill'); expect(history[1].to).toBe('fulfilled');});Testing that a guard blocks an event
Section titled “Testing that a guard blocks an event”import { defineDomain, when, on, guard, action, target } from '@tde.io/plexis';
const account = defineDomain('account', () => { when('open', () => { on('withdraw', () => { guard(async (ctx) => ctx.balance >= ctx.amount); action(async (ctx) => ({ balance: ctx.balance - ctx.amount })); return target('open'); }); }); return { context: { balance: 50, amount: 100 }, initial: 'open' };});
test('withdraw is blocked when balance is insufficient', async () => { const result = await account.follow('withdraw');
expect(result.status).toBe('blocked'); expect(account.context.balance).toBe(50); // unchanged});Testing a specific pipeline fork
Section titled “Testing a specific pipeline fork”Construct context to satisfy exactly the condition you want to exercise, then call pipeline.run():
import { definePipeline, node, action, fork, target, terminal } from '@tde.io/plexis';import { expect, test } from 'vitest';
type Ctx = { cardValid: boolean; charged: boolean };
const charge = definePipeline<Ctx>('charge', () => { node('check-card', () => { fork('charge', target('charge'), (ctx) => ctx.cardValid); fork('decline', target('decline'), (ctx) => !ctx.cardValid); }); node('charge', terminal()); node('decline', terminal()); return { initial: 'check-card' };});
test('valid card routes to charge', async () => { const result = await charge.run({ cardValid: true, charged: false });
expect(result.status).toBe('completed'); expect(result.finalNode).toBe('charge');});
test('invalid card routes to decline', async () => { const result = await charge.run({ cardValid: false, charged: false });
expect(result.status).toBe('completed'); expect(result.finalNode).toBe('decline');});Creating a fresh domain per test
Section titled “Creating a fresh domain per test”A single domain instance accumulates phase and history across calls. For isolated tests, create a new instance per test or use a factory:
function makeOrder() { return defineDomain('order', () => { when('pending', () => { on('submit', target('processing')); }); when('processing', terminal()); return { context: {}, initial: 'pending' }; });}
test('starts in pending', () => { const order = makeOrder(); expect(order.phase).toBe('pending');});
test('submit moves to processing', async () => { const order = makeOrder(); await order.follow('submit'); expect(order.phase).toBe('processing');});For followFrom() and history() signatures, see the Domain Instance reference.