Writing your first test
A .tflw file is a sequence of test blocks. A test is a name plus indented steps — no
boilerplate imports, no test-runner ceremony. (Diagnostics, autocomplete, and more are live as you
type in VS Code — see Editor support.)
test "health check" api GET /health expect status equals 200Blocks are indentation-delimited — the same offside rule Python uses. api steps issue an
HTTP request; expect is a hard assertion (it stops the test on failure). check is its soft
twin — it records a failure and keeps going, only failing the test at the very end:
test "product listing looks right" api GET /products expect status equals 200 check body.items has count 3 check header "content-type" contains "json"House style: expect for flow gates (nothing downstream makes sense if this fails), check for
final-state audits (report everything wrong at once).
Capturing values
Section titled “Capturing values”capture pulls a value out of a response and gives it a name; later steps reference it with
{name} string interpolation:
require env ADMIN_EMAIL, ADMIN_PW
session admin api POST /auth/login body { email: env(ADMIN_EMAIL), password: env(ADMIN_PW) } expect status equals 200 capture body.token as token header "Authorization" is "Bearer {token}"If the response has no such header or field, the capture fails rather than binding an empty
value — otherwise every later {name} would carry the literal text undefined, and an endpoint
that shrugs at ?id=undefined would leave you with a passing suite that checked nothing. A JSON
null is a real value and still captures fine.
This one goes in tflw.config, not the test file — sessions are project-wide. session blocks
like it run once per run, cached — a test opts in with as admin and
gets the session's captured headers auto-applied, no repeated login boilerplate. More on this in
Sessions & auth.
Chaining a captured value forward
Section titled “Chaining a captured value forward”Most real tests are a chain: create something, keep its id, use it, then assert against what the
server did with it. Each capture binds into the test's own scope, so a later step can interpolate
it into a path, a body, or another assertion's expected value:
test "an order carries the product it was created from" api POST /products body { name: unique("Widget"), price: 9.99 } expect status equals 201 capture body.id as productId
api POST /orders body { productId: {productId}, qty: 2 } expect status equals 201 capture body.id as orderId
api GET /orders/{orderId} expect status equals 200 expect body.productId equals {productId} expect body.total equals 19.98Two things worth noticing. The last assertion compares a server-computed total against a number the
test worked out itself, which is the difference between checking that an endpoint answered and
checking that it answered correctly. And {productId} is used twice — once to build a request,
once as an expected value — because a captured name is an ordinary value and not a special
request-building thing.
Capturing part of a value
Section titled “Capturing part of a value”When the value you want is inside a string — the id at the end of a location header, say — add
matching and a regular expression. The first group is captured, or the whole match when the
pattern has no group, and a pattern that finds nothing fails the step:
test "a created order is found at the address the server gave" api POST /orders body { productId: 1, qty: 2 } expect status equals 201 capture header "location" matching "/orders/(\\d+)" as orderId api GET /orders/{orderId} expect status equals 200A GraphQL request
Section titled “A GraphQL request”body graphql sends a query as GraphQL-over-HTTP's POST body — {"query", "variables", "operationName"} as JSON — with variables and operation optional, in that order:
test "an order is read back through the GraphQL endpoint" let orderId = 7 api POST /graphql body graphql "query O($id: ID!) { order(id: $id) { id total } }" variables { id: {orderId} } operation "O" expect status equals 200 expect body.data.order.id equals "7"The query is sent exactly as written: its braces are GraphQL's selection sets, never tflw's
{name}, so values go in variables — which is also GraphQL's own advice. A body graphql on a
GET is TF085.
Files in a multipart request
Section titled “Files in a multipart request”upload sends multipart/form-data: one or more files, comma-separated on the one line, each
"<path>" as "<field>", then any plain fields after form:
test "a ticket takes two screenshots and a note" api POST /tickets/42/attachments upload "./shots/before.png" as "files", "./shots/after.png" as "files", "./notes.txt" as "note" type "text/markdown" form title="Checkout hangs" expect status equals 201The parts go out in the order written. A field name may repeat, and each file becomes its own part
under it, which is what a files[] endpoint reads. Each file's Content-Type comes from its
extension unless it has its own type "…". Paths resolve against the test file's directory, and
tflw check warns about any literal path that names nothing (TF043). There is no trailing
comma: the list ends with its line, like form's.
What a failure looks like
Section titled “What a failure looks like”Say the API returns the unit price where the test expects a line total. Only the failing step prints, and it prints the moment it fails:
✗ an order carries the product it was created from (34 ms) ✗ expect body.total equals 19.98 expected body.total to equal 19.98, but got 9.99
FAIL 0/1 passed, 1 failed · env local · seed 868036364 · now 2026-08-23T09:12:44.180Z · 41 msThe four assertions above it passed and stayed quiet — by default a run prints one line per test and
expands only what broke. The test also stopped at that line: expect is hard, so the
capture-and-assert chain never continued past a response it could not trust. Had that been a
check, the run would have carried on and reported every other problem in the same pass.
Data-driven cases
Section titled “Data-driven cases”with each runs one reported case per row of an inline table:
with each | category | | "tools" | | "hardware" |test "creates a {category} product" as admin retry 1 api POST /products body { name: unique("Widget"), price: 12.5, category: {category} } expect status equals 201 check body.category equals {category}Each row shows up as its own pass/fail line in the report — not one aggregate assertion for the
whole loop. retry N re-runs a failing test up to N more times; a pass on a later attempt is
reported passed-but-flagged-flaky, never silently green. See
Data-driven tests & hooks and
Retry, polling & flaky handling for the full story.
Tags and running a subset
Section titled “Tags and running a subset”@smoke @orderstest "pay for an order" as admin api POST /orders/42/pay expect status equals 200--tag <name>[,<name>...] on tflw run filters to tests carrying any of the listed @names
(comma-separated OR; combines with --only as AND). A ! excludes: --tag !slow runs everything
not tagged @slow, and --tag smoke,!slow the smoke tests that are not slow.
Secrets (env(NAME)) are redacted from every report automatically — see
CI, reporting & safety. For what a run actually prints, --verbose,
isolating a failing test, and reading report.html, see
Running & debugging tests.
Worked examples
Section titled “Worked examples”tflw's dogfood repository keeps sixteen files that each teach one part of the language against a real API, with a comment on every step saying why it is written that way. They run in its CI, so they stay true.
| File | What it shows |
|---|---|
hooks-explained.tflw | before/after around each test, and before file/after file once |
capture-explained.tflw | capture — a response value bound for later steps |
request-shapes-explained.tflw | a request line's optional pieces: query, headers, body |
response-subjects-explained.tflw | what expect, check and capture can see after a response |
matchers-explained.tflw | the matcher set and not |
quantifiers-and-subset-explained.tflw | any/all over an array, and subset matching |
expressions-explained.tflw | arithmetic, comparisons and string building |
generators-explained.tflw | unique(...) and the other generators |
data-tables-explained.tflw | with each — one test per table row |
retry-explained.tflw | retry N on a test |
wait-until-api-explained.tflw | wait until — polling instead of failing fast |
tags-explained.tflw | @tags and --tag |
services-explained.tflw | more than one service in an env |
sessions-explained.tflw | sessions, and a test that runs as one |
actions-and-js-explained.tflw | action, import and a use helper |
security-explained.tflw | the security and config checks |