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}Asserting on a value
Section titled “Asserting on a value”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 itemsexpect {items[0].price} is greater than 0expect all {items.price} is greater than 0 # not `{items}.price`Two things to know:
- The braces are required.
expect total equals 5is an error, becausetext,status,list,field,page,requestandbuttonare subject keywords — a bare-name rule would turnlet text = "hi"into a UI assertion without telling you. tflw says so instead. - Only a name.
expect 2 equals 2stays ungrammatical; bind it first withlet sum = {a} + {b}.
capture vs. let
Section titled “capture vs. let”Both bind a name, and the difference is where the value came from:
| reads | fails when the value is missing | |
|---|---|---|
capture body.id as orderId | the system under test | yes — a response that didn't carry it is a real finding |
let orderId = 7 | your test | no — 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.
unique vs. random
Section titled “unique vs. random”Two generator families with opposite guarantees — see the full generator reference for every form:
unique(...)(andunique 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 likeis 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.)randomproduces reproducible-under---seedvalues for anything else. Allrandomvalues derive from one run seed with per-test sub-seeds; alltoday/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 fromrandom, neverunique.
Every form, and what each is for
Section titled “Every form, and what each is for”The reference table lists these; this is what each is actually good for.
| form | use it when |
|---|---|
random string 12 | you need N characters and nothing about them matters |
random uuid | an identifier the service will store and hand back |
random password 16 | a 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 past | a 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.
Expressions
Section titled “Expressions”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.
What { means
Section titled “What { means”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 variablestock.{key: value}is an object literal. It always requireskey: 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 }Value transforms
Section titled “Value transforms”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.