Control flow — browse guides

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

Step 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:

FaultWhat it looks like
A close with nothing openan end-loop closing a loop that was never opened
A crossed closean end-if that closes a loop — conditionals and loops must nest, not overlap
An open never closeda 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-if

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

The 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 of skipped rows means the loop never entered, not that something went wrong.
  • Hitting the cap is not recorded anywhere. The loop and end-loop markers are reported passed either 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 runs maxIterations times, 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:

ConditionTrue when
element-existsThe selector matches an element on the page.
element-not-existsThe selector matches nothing.
variable-equalsA variable equals a value exactly — a plain string comparison.
variable-containsA variable contains a substring.
url-matchesThe page URL matches a regular expression.
url-containsThe page URL contains a substring.

Three details worth having:

  • url-matches is the only one that takes a regular expression. The other two text conditions are literal contains/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-equals against '' is how you test "not captured".
  • The variable conditions read the same variable map the rest of the run uses, so anything captured by extract or api-request earlier 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