skip to content

Why can a Cypress .invoke() call the method more than once, and how do you stop it?

level: seniorimportance: should knowfreq 38%

answer

  1. A side effect nobody queued twice
  2. Queries re-run while later steps retry
  3. End the chain, do not extend it
  4. Move the call into a .then() callback
  5. The docs warn about this explicitly

basics

~20 s

As of Cypress 16, .invoke() is a query: it re-runs whenever a command chained after it retries, so the method fires again on every attempt. End the chain at .invoke(), or call the method yourself inside a .then() callback.

solid answer

~40 s

`.invoke(name, ...args)` looks like a one-shot call, but as of Cypress 16 it is a **query**. A query is re-evaluated each time a later command in the same chain has to try again, and re-evaluating `.invoke()` means calling the method again. For a pure reader such as `.invoke('text')` on a jQuery subject that is exactly what you want. For anything with a side effect it is a bug: `cy.window().its('catalogue').invoke('borrow', 'B-1204').should('have.property', 'copiesLeft', 2)` can borrow the book two or three times before the assertion settles, and the count it finally reads is not the one you meant to check. The Cypress docs are explicit about it: if you chain further commands off `.invoke()`, the function will be called multiple times. Stop the chain at `.invoke()`, or move the call into a `.then()` callback.

code

javascript · 12 lines
javascript
// borrow() fires again on every retry of the chained assertion
cy.window()
  .its('catalogue')
  .invoke('borrow', 'B-1204')
  .should('have.property', 'copiesLeft', 2)

// borrow() fires exactly once
cy.window()
  .its('catalogue')
  .then((catalogue) => catalogue.borrow('B-1204'))

cy.get('[data-cy=copies-left]').should('have.text', '2')

go deeper

for a junior

Know that .invoke() calls a method on the yielded subject and yields what it returns. Reading text or an attribute with it is safe; that is where you will meet it first.

for a middle

Explain why a query is allowed to run more than once, and name .invoke() as the query whose lookup happens to be a function call. Be able to say which of two chains calls the method once.

for a senior

An interviewer at this level expects the diagnosis: a count that is off by exactly the retry count, worse on slow CI, and confirmed by deleting the chained assertion. Then give the fix without hand-waving.

for a principal

Decide the standard. Whether specs may call application methods through .invoke() at all, or whether every state change has to go through the UI or a seeded fixture, is a suite-wide call with real maintenance consequences.

## Why the call repeats Cypress splits its steps into two kinds. **Actions** run once and stay run. **Queries** are pure lookups that Cypress is free to run again, and it runs them again whenever something later in the chain is still waiting to pass. As of Cypress 16 `.invoke()` is registered as a query - it sits in the same family as `.its()`, `.find()` and `.first()`. The trouble is that `.invoke()` is the one query whose "lookup" is a **function call**. Re-running `.its('copiesLeft')` re-reads a property, which is harmless. Re-running `.invoke('borrow', 'B-1204')` calls `borrow()` again. Cypress cannot tell a reader from a mutator, so it treats them alike, and the Cypress documentation carries the warning in plain language: *if you chain further commands off of `.invoke()`, it will be called multiple times.* ## What it looks like in a library catalogue suite ```javascript cy.window() .its('catalogue') .invoke('borrow', 'B-1204') .should('have.property', 'copiesLeft', 2) ``` The intent is obvious: borrow one copy, then check two are left. What happens is that the assertion fails on the first attempt (the application has not finished updating), Cypress retries, the whole query chain including `.invoke()` is evaluated again, `borrow()` fires a second time, and now three copies are gone. Either the assertion passes on a number you did not create, or it never passes and you spend an afternoon accusing the application. ## How to spot it - **The Command Log shows one `.invoke()` entry, the application shows two effects.** The log records the command, not the number of evaluations, so the count will not be on screen. - **The failure is timing-shaped.** It appears on the slow CI machine and not on the laptop, because a retry only happens when the first attempt did not pass. - **The numbers are out by exactly the retry count.** Two borrows, three borrows - never a random amount. - **Removing the chained assertion makes the symptom vanish.** That is the cleanest confirmation you will get, and it points straight at the mechanism. - **A spy or stub on the method records more calls than the test made.** If the method is one you already spy on, the call count is the fastest possible proof. None of these show up as a Cypress error. The test either fails with a puzzling number or - worse - passes, because the extra call happened to move the application into the state the assertion wanted. That is why the habit is worth building before you meet it: read every `.invoke()` in a review and ask whether the method name is a verb. ## Making the call happen exactly once 1. **End the chain at `.invoke()`.** Let the call be the last link, then start a new chain with `cy` for whatever you wanted to assert. This is the remedy the Cypress docs name first. 2. **Call the method inside a `.then()` callback instead.** `.then((catalogue) => catalogue.borrow('B-1204'))` invokes it in plain JavaScript, outside the query machinery. Note that chaining `.then()` *after* `.invoke()` does not help - the callback has to replace `.invoke()`, not follow it. 3. **Assert against the page, not against the return value.** In a catalogue suite the honest check is `cy.get('[data-cy=copies-left]').should('have.text', '2')`, which retries a query over the rendered DOM and mutates nothing. ## When repeating is harmless | the call | repeats safely? | why | |---|---|---| | `.invoke('text')` on a jQuery element | yes | re-reads the DOM, which is what a retry is for | | `.invoke('attr', 'data-book-id')` | yes | pure read of an attribute | | `.invoke('val')` on an input | yes | pure read of the current value | | `.invoke('borrow', 'B-1204')` | no | mutates catalogue state on every attempt | | `.invoke('show')` on a hidden panel | usually | idempotent, but it re-fires jQuery's effect each time | The rule of thumb: `.invoke()` is safe as a **getter** and dangerous as a **command**. If the method name is a verb that changes something, sends something, or is being counted by a spy, do not reach `.invoke()` for it. ## The related gotcha next door `.invoke()` also drops the implicit existence check that `.its()` adds. `.its()` keeps retrying until the property is neither `null` nor `undefined`; `.invoke()` has no such requirement, and instead errors when the named property is not callable - *"errored because the property: `length` returned a `number` value instead of a function."* That error is a good sign you wanted `.its()`. The repeated-call problem and the missing existence check are the two things that make `.invoke()` the sharpest edge in the subject-yielding family, and both come from the same fact: it is a query that happens to run your code.

  • Does chaining .then() after a Cypress .invoke() stop the method being called twice?
    No. Anything chained after `.invoke()` can cause the query to be evaluated again, and the Cypress docs use `.invoke('animate').then(() => {})` as their own example of a call that still fires repeatedly. The fix is to stop the chain at `.invoke()`, or to skip `.invoke()` and make the call inside the `.then()` callback.
  • When is a repeated Cypress .invoke() call harmless?
    When the method is a pure reader. `.invoke('text')` or `.invoke('attr', 'data-book-id')` on a jQuery subject simply re-reads the DOM, and re-reading is precisely what you want while an assertion retries. It only bites when the method mutates state, sends a request, or is being counted by a spy.

saying these in an interview costs you the question

  • Assumes .invoke() runs exactly once per test
  • Blames the application for the duplicated borrow
  • Chains .then() after .invoke() and calls it fixed
  • Cannot name the query model as the cause