Testing Actors
States are nodes, transitions are edges, a test is a walk. Mantaq is deterministic: same inputs, same trace, no sleeps.
basicInfo → shippingAddress → payment → submitting → success (final) ↕ (back) ↕ ↕ (back) errorThe machine
Section titled “The machine”import { Actor, VirtualClock, state, event } from "@mantaq/core";
type CheckoutContext = { basicInfo?: { email: string; name: string }; shippingAddress?: { street: string; city: string; zip: string }; paymentInfo?: { cardNumber: string }; orderId?: string;};
const basicInfo = state("basicInfo")();const shippingAddress = state("shippingAddress")();const payment = state("payment")();const submitting = state("submitting")();const success = state("success")().final();const error = state("error")();
const submitBasicInfo = event("submitBasicInfo")<{ email: string; name: string }>();const submitShipping = event("submitShipping")<{ street: string; city: string; zip: string }>();const submitPayment = event("submitPayment")<{ cardNumber: string }>();const back = event("back")();const paymentOk = event("paymentOk")<{ orderId: string }>();const paymentFail = event("paymentFail")<{ reason: string }>();const submittingDone = event("submittingDone")();
function createCheckoutActor() { return new Actor({ inputs: [submitBasicInfo, submitShipping, submitPayment, back], internal: [paymentOk, paymentFail, submittingDone], states: [basicInfo, shippingAddress, payment, submitting, success, error], initial: basicInfo, context: {} as CheckoutContext, setup: (m) => { m.on(basicInfo, submitBasicInfo, (e, { context }) => { context.set({ ...context.get(), basicInfo: e.payload }); return { state: shippingAddress }; }); m.on(shippingAddress, submitShipping, (e, { context }) => { context.set({ ...context.get(), shippingAddress: e.payload }); return { state: payment }; }); m.on(payment, submitPayment, (e, { context }) => { context.set({ ...context.get(), paymentInfo: e.payload }); return { state: submitting }; }); m.on(submitting, paymentOk, () => ({ state: success })); m.on(submitting, paymentFail, () => ({ state: error })); m.on(submitting, submittingDone, () => ({ state: success })); }, });}Happy path
Section titled “Happy path”const checkout = createCheckoutActor();expect(checkout.snapshot().path[0]).toBe("basicInfo");
checkout.send(submitBasicInfo.create({ email: "a@b.com", name: "A" }));expect(checkout.context.basicInfo).toEqual({ email: "a@b.com", name: "A" });expect(checkout.snapshot().path[0]).toBe("shippingAddress");
checkout.send(submitShipping.create({ street: "1 Main", city: "Oslo", zip: "0150" }));expect(checkout.snapshot().path[0]).toBe("payment");
checkout.send(back.create());expect(checkout.snapshot().path[0]).toBe("shippingAddress");
checkout.send(submitShipping.create({ street: "1 Main", city: "Oslo", zip: "0150" }));checkout.send(submitPayment.create({ cardNumber: "1111222233334444" }));checkout.send(paymentOk.create({ orderId: "ord_1" }));expect(checkout.snapshot().path[0]).toBe("success");expect(checkout.snapshot().done).toBe(true);Assert side effects with VirtualClock
Section titled “Assert side effects with VirtualClock”VirtualClock.advance(ms) is synchronous, fires timers, drains queue. No fake timers, no sleeps.
const clock = new VirtualClock();const checkout = new Actor({ inputs: [submitBasicInfo, submitShipping, submitPayment, back], internal: [submittingDone], states: [basicInfo, shippingAddress, payment, submitting, success, error], initial: basicInfo, clock, setup: (m) => { m.on(basicInfo, submitBasicInfo, () => ({ state: shippingAddress })); m.on(shippingAddress, submitShipping, () => ({ state: payment })); m.on(payment, submitPayment, () => ({ state: submitting })); m.on(submitting, submittingDone, () => ({ state: success })); m.effect(submitting, { name: "startSubmitTimeout", fn: ({ signal, emit, clock }) => { clock.setTimeout(800, () => { if (!signal.aborted) emit(submittingDone.create()); }); }, }); },});
checkout.send(submitBasicInfo.create({ email: "a@b.com", name: "A" }));checkout.send(submitShipping.create({ street: "1 Main", city: "Oslo", zip: "0150" }));checkout.send(submitPayment.create({ cardNumber: "1111222233334444" }));expect(checkout.snapshot().path[0]).toBe("submitting");
clock.advance(800);expect(checkout.snapshot().path[0]).toBe("success");Assert promise effects with settled
Section titled “Assert promise effects with settled”Promise effects emit in a microtask. settled() waits for them.
const checkout = new Actor({ inputs: [submitBasicInfo, submitShipping, submitPayment, back], internal: [paymentOk, paymentFail], states: [basicInfo, shippingAddress, payment, submitting, success, error], initial: basicInfo, context: {} as CheckoutContext, setup: (m) => { m.on(basicInfo, submitBasicInfo, () => ({ state: shippingAddress })); m.on(shippingAddress, submitShipping, () => ({ state: payment })); m.on(payment, submitPayment, () => ({ state: submitting })); m.on(submitting, paymentOk, () => ({ state: success })); m.effect(submitting, { name: "chargeCard", fn: ({ signal, emit }) => { Promise.resolve("ord_1").then((orderId) => { if (!signal.aborted) emit(paymentOk.create({ orderId })); }); }, }); },});
checkout.send(submitBasicInfo.create({ email: "a@b.com", name: "A" }));checkout.send(submitShipping.create({ street: "1 Main", city: "Oslo", zip: "0150" }));checkout.send(submitPayment.create({ cardNumber: "1111222233334444" }));
await checkout.settled();expect(checkout.snapshot().path[0]).toBe("success");