API testing
tapstep is not a Postman replacement — it makes the API a first-class citizen
inside a flow. Use request: steps for setup, teardown and backend checks,
and chain them with UI steps in the same file: what you create through the API
you verify on screen, sharing the same ${variables}.
A pure-API flow
Section titled “A pure-API flow”A flow with device: none never resolves or boots a device — it runs
anywhere, instantly:
device: noneenv: { API: "https://api.example.dev" }commands: - request: method: POST url: ${API}/staff auth: { bearer: "${STAFF_TOKEN}" } json: { email: "qa@test.dev", role: staff } extract: { staffId: $.id } # JSONPath → ${staffId} assert: status: 201 # a code, or a class: "2xx" json: { $.role: staff } - request: url: ${API}/staff/${staffId} retryUntil: { status: "2xx" } # poll every second until it passes timeout: 15000Under device: none only request, sql, scripts (evalScript,
runScript), value asserts (assertTrue) and control flow (runFlow,
retry, repeat) are allowed. The validate_flow tool of the MCP server (and
the chat’s write_test) flags a UI command up front; tapstep test has no
such lint, so there the step fails at runtime with this flow runs with device:
none — UI commands have no device. In the desktop app such flows run without
the device picker; in the CLI no device is probed or auto-booted — unless you
pass --driver or --browser, which override device: none and run the flow
on that target.
Chaining API and UI
Section titled “Chaining API and UI”In a normal (device) flow, request: steps mix freely with UI steps:
appId: https://staging.example.devcommands: - request: # arrange: create through the API method: POST url: ${API}/staff auth: { bearer: "${STAFF_TOKEN}" } json: { email: "qa@test.dev" } extract: { staffId: $.id } - openLink: "${APP}/staff/${staffId}" # act + assert: verify in the UI - assertVisible: "qa@test.dev"What request: supports
Section titled “What request: supports”http: is an alias of request: — same fields.
| Field | Meaning |
|---|---|
method, url |
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS; GET by default. - request: <url> is a GET shorthand. |
headers, query |
String maps, ${}-substituted. |
json / body |
Structured JSON body or a raw string — one or the other, never both. |
auth |
{ bearer: ${TOKEN} } or { basic: { user, pass } }. |
extract |
var: $.json.path — response values into ${var} for later steps. The step fails when the body is not JSON or a path matches nothing. |
assert |
status (code or "2xx"), json equalities or { contains: "…" }, headers, schema (a JSON Schema file next to the flow, or inline). |
retryUntil |
Same shape as assert, but polls every second until it passes or timeout expires — wait for the backend to reach a state. assert and retryUntil are mutually exclusive. |
timeout |
Per-request ms (default 30000); the polling deadline with retryUntil. |
${} is substituted in url, query, headers, auth, json and body —
not in the expected values under assert / retryUntil, which are compared
literally.
After every request ${response.status} and ${response.body} are available
to the following steps. On a failed assertion the report carries the method,
URL, status and a slice of the response body — in JUnit, Allure and HTML
output alike.
From curl, and back
Section titled “From curl, and back”A request you already have as a curl line does not need retyping:
pbpaste | tapstep convert --from curl # print the request steptapstep convert --from curl req.txt -o flows/api.flow.yaml --envtapstep convert --to curl flows/api.flow.yaml # every request step, back as curltapstep convert --to curl flows/api.flow.yaml --step 2 # just the 2ndThe step comes back with assert: { status: 2xx } unless --no-assert, and
what looked like a secret — a bearer token, a password — is lifted out into a
${VAR}; --env appends the names and values to the .env beside the flow
(a name already set there is left alone), so the flow itself stays
committable. -o writes a whole device: none flow instead of printing the
step. Flags curl has and a request step does not are reported rather than
dropped. The same two conversions are the MCP tools
convert_curl and request_to_curl, and the desktop offers the import from
the test editor.
Database checks
Section titled “Database checks”sql: steps run one statement against Postgres, MySQL or SQLite — the same
arrange / act / assert shape, one layer lower. Seed a fixture before the UI
touches it, check what the UI actually wrote, and clean up afterwards:
appId: https://staging.example.devdb: ${DATABASE_URL} # default for every sql stepbefore: - sql: # arrange: a fixture straight in the DB exec: "insert into coupons (code, percent) values ($1, $2)" params: ["QA10", 10] # $1, $2 on Postgres; ? on MySQL/SQLite assert: { affected: 1 }commands: - launchApp: https://staging.example.dev/checkout - inputText: "QA10" - tapOn: "Apply" - sql: # assert: what the UI wrote query: "select status, total from orders where coupon = $1" params: ["QA10"] readOnly: true # rolled back — cannot change data extract: { total: "$[0].total" } assert: rows: 1 first: { status: paid }after: - sql: { exec: "delete from coupons where code = $1", params: ["QA10"] }before: and after: are the flow’s setup and teardown — after runs after
the last step whatever happened, which is why the cleanup above still deletes
the coupon when a check failed (onFlowStart / onFlowComplete are the older
names of the two keys and still work). See
Flow file.
assert takes rows (exact count), minRows, none: true, first (columns
on the first row), contains (some row matches every column given) and
affected (exec only). extract runs a JSONPath over the result — the rows
array for query, { "affected": N } for exec — and after the step
${sql.rows} / ${sql.affected} are available to the following steps.
Values always go through params, never into the SQL text, so quoting and
injection are the driver’s problem, not yours. A ${var} param carries an
extracted value and binds as text, so on Postgres cast the placeholder when the
column is not text (where id = $1::int). Writes are allowed on purpose —
fixtures need them; the guard is the account you connect with, so point db:
at a read-only database user in suites that only check. The DSN is treated as a
secret: reports, events and logs show scheme://user:***@host/db, whether it
was typed inline or came from ${DATABASE_URL}.
Try a step before you run the flow
Section titled “Try a step before you run the flow”In the desktop app a request: or a sql: step has a Try button — on the
step’s own row and in its form. Try runs that one step, and nothing else: the
variables it resolves are the ones a run would resolve — the flow’s own env:
and params: defaults included, down to a header you have edited and not yet
saved — for the environment the title bar is switched to, and the flow file is
not touched. A ${var} an earlier step would have extracted is not there yet: a
try knows the project’s variables, not a run’s.
A try really sends the request and really runs the statement: a POST or a
sql: exec: changes the system the environment points at, exactly as a run
would.
The result opens under the step: the status and how long it took, the response
headers, the body, and every assert:/extract: key with what it saw. A sql:
step shows its rows in a table and the count above them. One try runs at a time
per step; closing the panel stops it.
The response is also how the checks get written. Click a value in the body and the panel offers two things:
- Extract — adds
extract: { <name>: "$.path" }, with the name proposed from the key you clicked. The path is the one the engine evaluates, quoted where the key is not a plain word ($.user['user-name']). - Assert equals — adds
assert: { json: { "$.path": <value> } }, with the value typed as the response has it: a number stays a number, a string stays a string.
Clicking the status adds assert: { status: 200 }. In a sql: result, clicking
a cell of the first row adds assert: { first: { <column>: <value> } } or an
extract of $[0].<column>, and clicking the row count adds
assert: { rows: N } (assert: { affected: N } after an exec). Each click
writes one key of that one step; nothing else in the file moves.
The db of a sql: step is a dropdown of the variables in the current
environment whose value looks like a connection string, shown as ${NAME} —
the connection string itself never appears on screen and never reaches the
YAML. Next to it, Check connects, asks the server its version and closes,
and says which stage failed when it cannot: DNS, connect, auth. From the
terminal the same check is tapstep sql --ping "${DATABASE_URL}".
Deliberately out of scope: load testing, mocks/stubs, GUI collections. For end-to-end work the common Postman moves — call, extract, assert, poll — are covered right in the flow.