SalesOS

Testing metadata

Test functions, flows, validation rules and formulas in your metadata package. Tests run on every check-only and real deploy, with every outside call mocked, and a failing test stops the deploy.

A metadata package can carry tests next to its source. They run when you deploy — check-only included — against the target org's metadata, before anything is written. If one fails, the deploy fails and nothing changes.

salesos test                     # run the project's tests against the active profile
salesos test --target production --json
salesos metadata deploy --target production   # runs the same tests first

Where tests live

TestPath (under sourceDir)Tests
Functionfunctions/<apiName>/tests/<name>.test.jsoninput + mocked secrets, fetch and salesos.* → output or error
Flowtests/flows/<name>.test.jsoninputs + screen answers + records → outputs, records written, path taken
Validation ruletests/validation-rules/<name>.test.jsonrecords → pass or fail, and the message
Formulatests/formulas/<name>.test.jsonrecords → the computed value

Names are letters, digits, dots, dashes and underscores. Unknown properties are rejected, like every other source file; the JSON Schema has functionTest, flowTest, validationRuleTest and formulaTest under $defs.

A test exercises the component in the package if it is there, otherwise the one already in the org — so a package can carry only tests, and salesos test --run-tests ALL re-checks what is deployed.

Function tests

The function runs in the same sandbox as production, with the same memory, CPU and output limits, but every call it makes out of the sandbox is answered from the test:

src/functions/score_lead/tests/hot-lead.test.json
{
  "description": "A known domain with a high score is band A",
  "input": { "email": "[email protected]" },
  "context": { "record": { "object": "lead", "id": "L1" } },
  "mocks": {
    "secrets": { "ENRICH_TOKEN": "test-token" },
    "fetch": [
      { "url": "https://api.enrich.example.com/score*", "method": "GET", "json": { "score": 91 } }
    ],
    "salesos": [
      { "op": "get", "object": "lead", "id": "L1", "result": { "id": "L1", "firstName": "Ada" } },
      { "op": "update", "object": "lead", "result": { "id": "L1" } }
    ]
  },
  "expect": {
    "output": { "band": "A" },
    "calls": [{ "op": "salesos.update", "object": "lead" }]
  }
}
  • mocks.secrets — what secrets.get(name) returns. A name that isn't here fails the call, as a missing secret would.
  • mocks.fetch[] — url exact or a prefix ending in *, optional method; answer with status, headers and body (text) or json, or error. The URL must still be in the function's allowedHosts, exactly as in production. A request with no mock fails: tests never reach the network.
  • mocks.salesos[] — op (get, list, create, update, delete, graphql), optional object and id to match, and result or error. Tests never touch CRM data; an unmocked call fails.
  • expect — any of status (SUCCEEDED by default, FAILED when you expect an error, TIMEOUT), output (deep equality), outputContains (subset), error (code — FUNCTION_ERROR for an uncaught throw — and a message substring), calls (host calls that must have happened, subset match) and logs (a substring of the output log).

Function tests are what the sandbox can prove: the code compiles, runs inside its limits, and behaves as expected for the inputs you give it.

Flow tests

The flow runs on the same runtime as production — the same element semantics, expressions, decisions, loops, faults and screen validation — but in isolation: interviews aren't saved, record steps read the test's data and record what they would write, custom actions and functions answer from mocks, approvals and email are captured. Nothing is written or sent.

src/tests/flows/big-budget.test.json
{
  "flow": "qualify_lead",
  "record": { "object": "lead", "id": "L1", "company": "Acme" },
  "inputs": { "source": "web" },
  "answers": [
    { "screen": "budget", "values": { "budget": 50000 } }
  ],
  "data": {
    "lead": [{ "id": "L1", "company": "Acme", "status": "NEW" }]
  },
  "mocks": {
    "actions": { "check_credit": { "output": { "approved": true } } }
  },
  "expect": {
    "status": "COMPLETED",
    "outputs": { "outcome": "qualified" },
    "path": ["budget", "route", "create_task"],
    "notPath": ["nurture"],
    "records": [{ "op": "create", "object": "task", "data": { "subject": "Call Acme" } }]
  }
}
  • answers[] are submitted in order; each names the screen it answers. A screen that rejects the values fails the test with the field errors.
  • data holds the records Get Records and record lookups can see, per object (lead, task, custom:invoice…).
  • mocks.actions / mocks.functions answer custom action and function steps by apiName ({ "output": {…} } or { "error": { "code", "message" } }).
  • expect — status (COMPLETED by default, FAILED, PAUSED), outputs (subset of the output variables), path (element ids that ran, in order), notPath, records (writes that must happen: op, object, data subset), recordWrites (exact count), emails and error.

Subflows resolve from the package, else the org's active version.

Validation rule and formula tests

src/tests/validation-rules/close-date.test.json
{
  "rule": "OPPORTUNITY:Close date required",
  "cases": [
    { "name": "missing", "record": { "stage": "NEGOTIATION", "closeDate": null }, "expect": "fail", "message": "close date" },
    { "name": "set", "record": { "stage": "NEGOTIATION", "closeDate": "2026-12-01" }, "expect": "pass" },
    { "name": "changed", "previous": { "stage": "QUALIFICATION" }, "record": { "stage": "NEGOTIATION" }, "operation": "UPDATE", "expect": "fail" }
  ]
}
src/tests/formulas/score-band.test.json
{
  "field": "LEAD:score_band",
  "cases": [
    { "record": { "leadScore": 90 }, "expect": "A" },
    { "record": { "leadScore": 10 }, "expect": "C" }
  ]
}

A rule test evaluates the rule exactly as a save would (appliesTo, CHANGED operators against previous, the rendered message). A formula test names a FORMULA custom field (ENTITY:name) or a custom object's formula field (custom:<object>.<field>). Up to 50 cases each.

Which tests run

POST /v1/metadata/deploy takes runTests:

runTestsRuns
SPECIFIED (default)The tests in the package
ALLThe package's tests plus every test earlier deploys left in the org (the package's version of a file wins)
NONENothing — refused by the org policy below for changed functions and flows

A successful real deploy keeps its tests in the org (deleting a function, flow or rule removes its tests), and a full salesos metadata pull hands them back as files. Results are on the deploy (GET /v1/metadata/deploys/:id → testResults, and Settings → Developer → Deployments): one row per test with status (passed, failed, error, skipped), time, where it came from (package or org), why it failed, and details — the output and host calls of a function, the path, outputs and record writes of a flow, each rule or formula case.

Limits

Tests run one at a time. Each function or flow test gets at most 10 seconds (and never more than the function's own timeout); a deploy runs at most 100 tests within 120 seconds — tests left when the budget runs out are reported as not run, which fails the deploy.

Org policy

An org owner or admin can require tests: with Functions need tests and/or Flows need tests on (Settings → Developer → Deployments, or PUT /v1/metadata/test-policy { "requireTestsFor": ["FUNCTION", "FLOW"] }), every function or flow a deploy creates or changes needs at least one test in the run that targets it, or the deploy fails on that component. Only a signed-in person can change the policy — an API key or OAuth token gets 403 SESSION_REQUIRED — so a CI credential can't switch off the rule that constrains it.

On this page