Skip to content

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 flow with device: none never resolves or boots a device — it runs anywhere, instantly:

device: none
env: { 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: 15000

Under 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.

In a normal (device) flow, request: steps mix freely with UI steps:

appId: https://staging.example.dev
commands:
- 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"

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.

A request you already have as a curl line does not need retyping:

Terminal window
pbpaste | tapstep convert --from curl # print the request step
tapstep convert --from curl req.txt -o flows/api.flow.yaml --env
tapstep convert --to curl flows/api.flow.yaml # every request step, back as curl
tapstep convert --to curl flows/api.flow.yaml --step 2 # just the 2nd

The 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.

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.dev
db: ${DATABASE_URL} # default for every sql step
before:
- 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}.

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.