skip to content

Two scenarios in one Karate feature each need a different login, and both go through `* def token = callonce read('login.feature') creds` where `creds` is set per scenario. Both end up with the first scenario's token — why, and how do you fix it?

level: seniorimportance: should knowfreq 40%

answer

  1. The lookup happens before the argument does
  2. Two identical source lines, one entry
  3. Keyed on text, not on value
  4. A suffix exists to split a key
  5. Varying input means it is not shared setup

basics

~20 s

The callonce cache is keyed on the expression text, not on the argument's value. Both steps carry identical text, so they share one entry and the second scenario gets the first one's token. Fix it by making the expressions differ.

solid answer

~50 s

`callonce` keys its cache on the **text of the call expression** after the keyword. Both scenarios execute a step whose text is `read('login.feature') creds` character for character, so they hit one cache entry — the value that `creds` happens to hold at lookup time is never consulted. The first scenario executes the login and stores it; the second gets that copy back and never signs in as its own user. The fixes follow from the key. Either make the two expressions genuinely different text, by inlining the differing argument as a literal so the steps no longer match, or stop using a per-feature cache for something that varies and call `karate.callSingle('login.feature?admin', {...})` and `...?user`, where the `?suffix` splits the path key while the same file is read. If the login is genuinely per scenario, it should not be cached at all — use plain `call`.

code

gherkin · 7 lines
gherkin
# WRONG - one cache key, so the second scenario reuses the first token
# * def creds = { user: 'admin' }
# * def token = callonce read('login.feature') creds

# RIGHT - the two expressions differ as text, so they cache separately
* def adminToken = callonce read('login.feature') { user: 'admin' }
* def guestToken = callonce read('login.feature') { user: 'guest' }

go deeper

for a junior

Take away the headline: two callonce steps whose text matches share one cached result, whatever variables they mention.

for a middle

Explain that the cache lookup happens on the expression text before the argument is evaluated, which is why the value never reaches the key.

for a senior

Diagnose from the symptom — a valid token for the wrong account — and choose between not caching, splitting the expression text, and a ?suffix on callSingle.

for a principal

Set the review rule that a cached call's argument must be constant for the whole feature, so this class of silent cross-scenario leakage cannot enter the suite.

## The mechanism behind the surprise When Karate executes a `callonce` step it takes the text after the keyword — the whole call expression, `read('login.feature') creds` here — and uses that string as the cache key. It looks the string up first, and only on a miss does it evaluate the expression, run the callee and store the result under that same string. Nothing about the *value* of `creds` participates. The lookup happens before the argument is evaluated, so two scenarios that differ only in what they bound to `creds` are, to the cache, the same call. The evidence that this is textual rather than semantic is visible in how carefully the key is assembled: when the argument is supplied as a doc-string under the step, that doc-string is appended to the key precisely so that two steps differing only in their doc-string cache separately. ## Why it reads as correct code The step looks parameterised. A reader sees a variable being passed and assumes the cache is keyed on the call *and* its input, the way a memoized function would be. It is not — it is keyed on the source line. The failure is silent: the second scenario gets a perfectly well-formed token belonging to the wrong user, and the assertion that eventually fails is somewhere downstream, usually a 403 or a payload holding another account's records. The same shape catches people with a `Scenario Outline`. Every `Examples` row re-runs the `Background`, and every row's step has identical text, so a `callonce` there is one execution for the whole table — which is exactly what you want when the setup is shared, and exactly wrong when the row is supposed to drive it. ## Three fixes, in the order you should consider them 1. **Do not cache it.** If each scenario genuinely needs its own sign-in, the setup is not shared and `call` is the correct keyword. Caching something that varies per scenario is the actual mistake; the key is only how it surfaces. 2. **Make the expressions different text.** Inline the differing argument as a literal so the two steps no longer match: ```gherkin * def adminToken = callonce read('login.feature') { user: 'admin' } * def userToken = callonce read('login.feature') { user: 'guest' } ``` Two distinct keys, two executions, each cached for the rest of the feature. This works because the key is the text, and it fails the moment someone refactors the literals back into a variable. 3. **Use the cache built for two variants of one file.** `karate.callSingle()` keys on the path string, and Karate provides a `?name` suffix specifically to split that key. The suffix is stripped before the file is read, so both calls run the same feature: ```javascript var admin = karate.callSingle('classpath:login.feature?admin', { user: 'admin' }); var guest = karate.callSingle('classpath:login.feature?guest', { user: 'guest' }); ``` This also widens the cache from the feature to the whole run, which is usually what you wanted for a login anyway. ## Reviewing for it The rule that catches this in review is short: **the argument to a `callonce` should be a constant of the feature, not of the scenario.** Concretely, treat these as suspicious: - a `callonce` whose argument is a variable that any scenario or `Examples` row assigns; - a `callonce` inside a `Scenario Outline`'s `Background` that references a placeholder-derived value; - two `callonce` steps in one file whose text matches but whose intent differs. ## Two related boundaries worth knowing The cache lives on the running feature, so this collision is confined to one file — a second feature file with the same step text has its own entry and signs in again. And a feature that is itself being *called* gets a fresh cache per invocation, so a `callonce` inside a called feature invoked once per outline row does re-execute per row rather than freezing on the first caller's scope. That asymmetry surprises people in the other direction: they expect a `callonce` in a called feature to de-duplicate across invocations, and it does not. Finally, note what does **not** rescue you here. A cache hit hands back a copy, so mutating the returned token in the second scenario does not fix anything — it was the wrong token before you touched it. And a failed call is not cached at all, so a login that throws will be retried; only a *successful* wrong-user login is sticky.

  • In Karate, does the same collision happen with `karate.callSingle()`, and how is it handled there?
    Yes, for the same reason at a different granularity: `callSingle` keys on the path string and ignores the argument, so the same file called with two arguments collides. Karate answers it with a `?name` suffix on the path — `login.feature?admin` and `login.feature?guest` are two keys, the suffix is stripped before the file is read, and both calls run the same feature.
  • In Karate, why does a `callonce` in the `Background` of a `Scenario Outline` execute only once for the whole table?
    The Background is re-evaluated for every Examples row, but each row's step carries the same expression text and the feature-level cache is shared by all of them, so the first row executes the callee and the rest take the cached copy. That is the intended saving when the setup is shared, and a defect when the row is supposed to drive the call.

It is a cache keyed on the source line rather than on the arguments — like memoizing by the name of the function instead of by what you passed it.

saying these in an interview costs you the question

  • Says the argument value is part of the callonce key
  • Blames parallel execution rather than the cache key
  • Suggests renaming the variable to force a new entry
  • Thinks copying the result before use fixes it
  • Assumes each scenario gets its own callonce cache
  • Reaches for a sleep or a retry instead of a second key