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.
A test file
Section titled “A test file”Tests live in the motion’s tests/ folder and use Vitest:
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:
npx motions testrunStep
Section titled “runStep”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.
Testing questions
Section titled “Testing questions”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.
Fixtures
Section titled “Fixtures”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 reads and proposals
Section titled “CRM reads and proposals”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:
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.
Batch steps
Section titled “Batch steps”Pass companies instead of company, and read results by company key. See Batch steps.
Before you push
Section titled “Before you push”npx motions check # compiles every step and validates motion.yamlnpm run typecheck # tscnpx motions test # the motions' testsThe 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