Skip to content

Variables, generators & expressions

let declares a variable; {name} interpolates it anywhere a value goes — request bodies, table cells, expect values:

test "creates a widget with a random price"
let price = random decimal 5 to 50
api POST /widgets body { name: "Random Widget", price: {price} }
expect status equals 201
expect body.price equals {price}

A bound value can stand on the left of a matcher, not just the right:

test "the refresh really rotates the token"
api POST /auth/login body { email: "a@b.c", password: "pw" }
capture body.accessToken as first
api POST /auth/refresh
capture body.accessToken as second
expect {second} not equals {first}

Without that last line the test passes even if the API hands back the same token and merely resets its expiry — green suite, rotation broken.

Paths and array quantifiers work the same as on a response body, as long as the whole path lives inside the braces:

capture body.items as items
expect {items[0].price} is greater than 0
expect all {items.price} is greater than 0 # not `{items}.price`

Two things to know:

  • The braces are required. expect total equals 5 is an error, because text, status, list, field, page, request and button are subject keywords — a bare-name rule would turn let text = "hi" into a UI assertion without telling you. tflw says so instead.
  • Only a name. expect 2 equals 2 stays ungrammatical; bind it first with let sum = {a} + {b}.

Both bind a name, and the difference is where the value came from:

readsfails when the value is missing
capture body.id as orderIdthe system under testyes — a response that didn't carry it is a real finding
let orderId = 7your testno — you supplied the value

That is why capture {orderId} as savedId is rejected: it reads nothing from the system under test, so it is just let savedId = {orderId} with a second name.

Two generator families with opposite guarantees — see the full generator reference for every form:

  • unique(...) (and unique email/unique number/unique like "..."/unique uuid) guarantees a collision-free value across the whole run, including retries — its counter keeps advancing on every retry attempt of the same test, by design, so a retried attempt never collides with data the failed attempt already created. That also means it cannot reproduce a value an earlier attempt used. It is also collision-free between runs: every value carries a run namespace taken from the run clock, so a second run against a database the first one wrote to does not re-issue the first run's values. (unique like is the one exception, and only across runs: its namespace permutes the pattern's value space rather than widening it, so two runs drawing from a narrow pattern can still land on a shared code.)
  • random produces reproducible-under---seed values for anything else. All random values derive from one run seed with per-test sub-seeds; all today/now-derived values derive from one run clock (the real instant, or --now <iso> to pin it). Anything a retry needs to reuse identically across its own attempts must come from random, never unique.

The reference table lists these; this is what each is actually good for.

formuse it when
random string 12you need N characters and nothing about them matters
random uuidan identifier the service will store and hand back
random password 16a credential that has to satisfy an ordinary complexity rule
random of "red", "blue", "green"the field is an enum and the service rejects anything else
random like "SKU-####-??"the field has a shape — # is a digit, ? a letter
random date in pasta date that must be before now; in future and between A and B too
unique("Widget")a prefixed name you will search for later and must not collide

unique("prefix") is the general form of the unique family: everything after the prefix is the collision-free part, so unique("Widget") yields a name you can assert on and still run twice.

One sentence each, and they are opposites: random moves with --seed and not with the run clock; unique moves with --now and not with the seed. That is what tells unique like and random like apart — they share a pattern language and differ in nothing else.

tflw run --seed <s> --now <iso> together reproduce a run's exact absolute generated values — every generated value is shown inline at its step in the report (qty = 100 (random)). Pinning --now pins the run namespace too, so two runs pinned to the same instant draw the same unique values on purpose: against a live database that outlives the run, pin the seed and leave the clock alone.

A closed grammar, usable in let, fills, api bodies, table cells, expect values:

  • Arithmetic on numbers: {price} * {qty}, + - * /.
  • Interpolation in strings: "Order {orderId} for {name}".
  • Date math: today, now, today + 3 days, now - 2 hours; format {d} as "yyyy-MM-dd" (project default format in config).
  • Hard fence: no conditionals, no loops, no boolean operators — reach for the JS escape hatch instead.

One value grammar serves every position — a let, a fill, an api body, a table cell, and every matcher operand alike — so { means the same thing everywhere:

  • {ref}, {ref.path}, {ref[0]} are interpolation. {stock} is the variable stock.
  • {key: value} is an object literal. It always requires key: value.

That second half is a promise, not an implementation detail: tflw will never grow a JavaScript-style shorthand-key form, because {stock} meaning {stock: stock} is permanently spoken for by interpolation. It's what lets these two lines sit next to each other and each mean the obvious thing:

expect body.stock equals {stock}
expect body matches subset { id: 1 }
test "transforms a value with base64/hex/url encoding"
let token = base64 encode("hello")
let decoded = base64 decode({token})
let hexToken = hex encode("hello")
let urlSafe = url encode("a b")

Pure transforms — unlike generators, these consume an existing value rather than manufacture a fresh one.

Two more work on strings and lists:

test "a list's length, and the list as one string"
api GET /orders
capture body.ids as ids
let count = length of {ids}
let csv = {ids} joined with ","
api GET /orders/summary?ids={csv}
expect body.count equals {count}

length of reads what .length reads, on a string or a list, and binds tighter than arithmetic — length of {ids} + 1 is the length plus one. joined with turns a list of strings and numbers into one string and binds loosest of all. That is the whole of it: there is no + on strings, because interpolation already puts two together — "{first}{last}".

Full reference: SPEC.md §7.