Skip to content

Test a motion

A step is a function of the company, the sources’ answers and the CRM. runStep from @motionskit/sdk/testing runs it with all three under your control, in milliseconds, without the network. Write a test for every path through a step: each drop, each question, each kind of answer.

Tests live in the motion’s tests/ folder and use Vitest:

motions/domain-check/tests/domain.test.ts
import { runStep } from "@motionskit/sdk/testing";
import { expect, test } from "vitest";
import domain from "../steps/domain.ts";
// The tenant's recorded RDAP answers. Inside the tenant folder runStep finds them on its own; naming the folder lets the test run from anywhere.
const fixtures = new URL("../../../fixtures/sources/", import.meta.url).pathname;
const now = "2026-11-06T12:00:00Z";
test("drops a company with no domain, without calling RDAP", async () => {
const out = await runStep(domain, { company: { core: { name: "Café Bandeirante", domain: null } }, fixtures, now });
expect(out).toMatchObject({ status: "ok", outcome: { lane: "drop", reason: "no_domain" } });
expect(out.calls).toEqual([]);
});
test("records who registered the domain, and since when", async () => {
const out = await runStep(domain, { company: { core: { name: "Metalúrgica Aurora", domain: "aurorametais.example" } }, fixtures, now });
expect(out).toMatchObject({
status: "ok",
facts: { domain: "aurorametais.example", registrant: "METALURGICA AURORA LTDA", since: 2009, years: 17 },
});
});
test("strips www before the lookup", async () => {
const out = await runStep(domain, { company: { core: { name: "Laticínios Serra Verde", domain: "www.serraverde.example" } }, fixtures, now });
expect(out.calls.map((c) => [c.op, c.request])).toEqual([["rdap.lookup", { domain: "serraverde.example" }]]);
});
test("drops a domain nobody registered", async () => {
const out = await runStep(domain, { company: { core: { name: "Nuvem Clara Tecnologia", domain: "nuvemclara.example" } }, fixtures, now });
expect(out).toMatchObject({ status: "ok", outcome: { lane: "drop", reason: "unregistered" } });
});
test("asks when someone else registered the domain", async () => {
const out = await runStep(domain, { company: { core: { name: "Pátio Logística", domain: "patiolog.example" } }, fixtures, now });
expect(out).toMatchObject({ status: "asked", question: { type: "domain_owner", recommended: "same" } });
});
test("keeps going once someone answers that it is the same company", async () => {
const out = await runStep(domain, {
company: { core: { name: "Pátio Logística", domain: "patiolog.example" }, answers: { domain_owner: "same" } },
fixtures,
now,
});
expect(out).toMatchObject({ status: "ok", facts: { registrant: "GRUPO TRANSVIA PARTICIPACOES LTDA", since: 2018 } });
});

Run them with:

Terminal window
npx motions test
const out = await runStep(step, {
company: { key: "203", core: { name: "Pátio Logística", domain: "patiolog.example" }, facts: {}, answers: {} },
fixtures, // a fixtures/sources folder, or entries; default: the tenant's own folder
crm: { contacts: [...] }, // records ctx.crm reads return
rules: { young_years: 3 },
now: "2026-11-06T12:00:00Z",
calendar: { manager_answers: Date.parse("2026-11-09T21:00:00Z") },
});

Everything is optional. A company left out gets a neutral one, and fields you don’t give are null.

out is the run’s result, plus calls, every source call the step made with its request and reply:

out.status The step Also in out
ok returned facts, outcome, questions, events, proposals
asked called ctx.ask on a question with no answer yet question
waiting called ctx.wait until, reason
parked stopped on a budget or credit refusal reason
failed threw, or a source call failed error, transient

Every result has logs, what the step printed.

Test both sides of a question. First that the step asks, then that it carries on once answered, by giving the answer in company.answers:

const asked = await runStep(domain, { company: { core: { name: "Pátio Logística", domain: "patiolog.example" } } });
expect(asked).toMatchObject({ status: "asked", question: { type: "domain_owner" } });
const answered = await runStep(domain, {
company: { core: { name: "Pátio Logística", domain: "patiolog.example" }, answers: { domain_owner: "same" } },
});
expect(answered).toMatchObject({ status: "ok" });

Answers with a hold or drop effect never reach the step, so there’s nothing to test for them.

Source calls are answered from fixtures, as in a tenant in fixture mode. By default runStep reads the tenant’s fixtures/sources/ folder, found from the working directory. Pass a folder path, or an array of entries for a test of its own:

const fixtures = [
{ op: "exa.search", request: {}, error: { code: "rate_limited" as const, message: "slow down" } },
];
const out = await runStep(signals, { company, fixtures });
expect(out).toMatchObject({ status: "failed", transient: true });

A step that calls a source with no matching fixture fails with “no fixture matches …”. For the fixture format, see Fixtures.

crm gives the records ctx.crm reads return, in Motions’ shape (first_name, not firstname), filtered as the test CRM filters them. What the step proposes comes back as proposals:

motions/domain-check/tests/note.test.ts
import { runStep } from "@motionskit/sdk/testing";
import { expect, test } from "vitest";
import note from "../steps/note.ts";
const found = { domain: "patiolog.example", registrant: "GRUPO TRANSVIA PARTICIPACOES LTDA", since: 2024, years: 2 };
test("proposes a note for the changeset instead of writing it", async () => {
// This step calls no source, so it needs no fixtures.
const out = await runStep(note, { company: { key: "203", facts: { domain: found } }, rules: { young_years: 3 }, fixtures: [] });
expect(out).toMatchObject({
status: "ok",
proposals: [{ action: "notes.create", target: "203", values: { body: expect.stringContaining("is young (2 years)") } }],
});
});

now fixes the run’s clock: ctx.now(), new Date() and Date.now() all return it. Without it, they return the real time, and a test that depends on the date will break next year.

Pass companies instead of company, and read results by company key. See Batch steps.

Terminal window
npx motions check # compiles every step and validates motion.yaml
npm run typecheck # tsc
npx motions test # the motions' tests

The GitHub workflow runs the same three on every pull request.

Motions is built by Pruma. These docs describe what the platform does today; Limitations lists what it doesn't do yet. Open the app