Skip to content

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)
error
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 }));
},
});
}
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);

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");

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");