Control flow — if, else and loop
Conditional branches and loops: how the flat marker steps pair up, the six condition types, and why AI-generated tests never contain them.
Tests are not always straight lines. A cookie banner may or may not appear; a list may have one page or six. Vera has two control-flow constructs for that, and one structural rule you need to know before you use them.
Flat with markers, not nested blocks
if, else, end-if, loop and end-loop are individual steps in the same
flat list as everything else. They are not containers holding children.
1 goto /dashboard
2 if element-exists .cookie-banner
3 click .cookie-banner .accept
4 end-if
5 assert visible .welcomeStep 3 is not "inside" step 2 in the data — the list is flat, and steps 2 and 4
are markers that the runner pairs up. The builder indents the steps between them
so it reads as a block. else, end-if and end-loop carry no fields at all;
they are pure punctuation.
The runner pairs markers with depth-aware matching, so nesting works correctly: an
if inside an if finds its own end-if, not the inner one's. There is no
maximum nesting depth.
The markers have to balance, and that is checked when you save
A step list whose markers do not pair up is rejected on save, with a message against the offending step. Three faults are named:
| Fault | What it looks like |
|---|---|
| A close with nothing open | an end-loop closing a loop that was never opened |
| A crossed close | an end-if that closes a loop — conditionals and loops must nest, not overlap |
| An open never closed | a loop with no end-loop |
The same rule guards extracting a selection into a reusable flow:
you cannot lift an if out of a test and leave its end-if behind.
The runner is deliberately the lenient side of this. An orphaned end-if that
somehow reached execution is recorded passed and the run carries on rather than
dying on punctuation — which is exactly why the refusal lives at the save door,
where a person is there to fix it.
What is not checked is intent. Dragging a step out from between two markers is a perfectly legal edit, and it stops that step being conditional. The builder's indentation is the only signal of where the boundaries currently are, so watch it when you reorder.
if / else / end-if
if <condition>
…steps run when the condition is true…
else ← optional
…steps run when it is false…
end-ifThe condition is evaluated once, when the if step is reached. If it is false and
there is no else, the runner jumps past the end-if.
loop / end-loop
loop <condition>
…steps repeated while the condition holds…
end-loopThe condition is re-evaluated before every iteration. A loop whose condition never
goes false would run forever, so every loop carries a maximum iteration
count — maxIterations, a per-step field that defaults to 10 and that the
builder lets you set between 1 and 100. When the cap is hit the loop simply exits
and the run continues at the step after the end-loop.
Two things follow from "simply exits", and the second is the one that bites:
- A loop whose condition is false on the first check runs zero times, and every
step in its body is recorded
skipped— so a body ofskippedrows means the loop never entered, not that something went wrong. - Hitting the cap is not recorded anywhere. The
loopandend-loopmarkers are reportedpassedeither way, so a run that exhausted its iterations looks exactly like one whose condition went false. Treat the cap as a safety net, not a control: if a loop reliably runsmaxIterationstimes, the condition does not describe what you meant and the report will not tell you so.
Conditions
Both constructs use the same six condition types:
| Condition | True when |
|---|---|
element-exists | The selector matches an element on the page. |
element-not-exists | The selector matches nothing. |
variable-equals | A variable equals a value exactly — a plain string comparison. |
variable-contains | A variable contains a substring. |
url-matches | The page URL matches a regular expression. |
url-contains | The page URL contains a substring. |
Three details worth having:
url-matchesis the only one that takes a regular expression. The other two text conditions are literalcontains/equality, so a.in them means a full stop. A risky pattern — nested quantifiers such as(a+)+, which can hang the runner on a long URL — is rejected rather than compiled, both when you save the step and if one reaches execution anyway.- A variable that was never set reads as the empty string, so
variable-equalsagainst''is how you test "not captured". - The variable conditions read the same variable map the rest of the run uses, so
anything captured by
extractorapi-requestearlier is available — that is the usual way to branch on something the app told you. See Variables.
When the condition itself cannot be evaluated
A condition that throws — an uncompilable pattern, a selector query the page
rejected — does not quietly count as false. The if or loop step is marked
failed, with if condition could not be evaluated: … as its error, and the
run fails.
That is a deliberate choice rather than an oversight. Swallowing the error to
false made "I could not tell" indistinguishable from "it was false": the if
took the else branch, the loop never ran a single iteration, and both went
green while proving nothing.
The two constructs then differ in what they do with the rest of the block. A
failed if condition does not skip the block — the steps after it still run,
so the failure is loud rather than quiet. A failed loop condition runs no
iterations and records the end-loop skipped.
Worked example: an optional login
1 goto /app
2 if element-exists [data-testid=login-form]
3 fill [data-testid=email] {{email}}
4 fill [data-testid=password] {{password}}
5 click [data-testid=submit]
6 wait selector [data-testid=dashboard]
7 end-if
8 assert visible [data-testid=dashboard]This passes whether or not the session was already authenticated, which is what makes it safe to run both against a fresh browser and after a captured session.
Worked example: paging through a list
1 goto /orders
2 loop element-exists [data-testid=next-page]:not([disabled])
3 click [data-testid=next-page]
4 wait selector [data-testid=order-row]
5 end-loop
6 assert visible [data-testid=orders-empty-next]A note on AI-generated tests
Vera's AI generator does not emit if or loop. That is deliberate: a
branch an AI guessed at is a branch nobody verified, and a wrong loop condition
fails in a way that looks like a flaky test. Where branching is genuinely needed,
it is assembled deterministically from what was actually observed on the page, or
you add it yourself in the builder.
The mechanism is a closed list rather than a prompt instruction: the generator's
normalizer accepts about half of the forty action types and drops anything else
with a warning, so a model that invents a loop produces no step at all. The
markers are not the only verbs held back that way — handle-dialog,
assert-console, assert-request, go-back, go-forward, reload,
switch-tab and drag are outside the generated vocabulary too, on the same
reasoning. Every one of them is available to you in the builder; see the
actions reference. The same normalizer gates the AI chat editor
and the multi-page journey walker, so none of those three surfaces can write a
branch either.
See also
- Step actions reference — every action, including the markers
- Variables — what the variable conditions read
- The test builder — the indentation that shows the nesting