Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

Free tool

Cypress test reviewer

Paste a Cypress spec and see what a careful reviewer would question, from flaky waits to APIs Cypress 16 removed. Same input, same findings, every time.

Runs entirely in your browser. Your code is never sent anywhere and never executed.

Verdict

Needs changes

7 high4 medium5 low2 tests · 2 assertions
  • HighFocused test (.only)Line 2
    it.only('guest can pay', async () => {

    `.only` runs just this test or group and silently skips the rest of the spec. Remove it before merging.

    No ESLint rule for this checkCypress docs
  • Highasync test functionLine 2
    it.only('guest can pay', async () => {

    Cypress commands aren't promises. An async test returns a promise, which Cypress rejects or runs out of order. Remove `async` and chain with `.then()` when you need a value.

    No ESLint rule for this checkCypress docs
  • HighCypress command assigned to a variableLine 4
    const payButton = cy.get('.btn-primary')

    Cypress commands are queued and run later, so the variable doesn't hold the element or value. Use an alias (`.as('name')`) or `.then()`. Only `cy.stub()` and `cy.spy()` return values you can assign.

    ESLint: cypress/no-assigning-return-valuesCypress docs
  • HighHardcoded credentialLine 6
    cy.get('input[type="password"]').type('••••••')

    A credential is written into the test, where anyone with the repository can read it, and Cypress prints typed text in the command log. Read it with `cy.env()` and type it with `{ log: false }`.

    No ESLint rule for this checkCypress docs
  • HighFixed wait (cy.wait with a number)Line 8
    cy.wait(3000)

    A fixed wait slows every run and still fails when the app is slower. Wait for the request with an alias, e.g. `cy.intercept(…).as('save')` then `cy.wait('@save')`, or assert on the element with `.should(…)`, which retries.

    ESLint: cypress/no-unnecessary-waitingCypress docs
  • HighCypress.env() removed in Cypress 16Line 11
    if (Cypress.env('SKIP_RECEIPT')) {

    Cypress 16 removed `Cypress.env()`. Read secrets with `cy.env(['NAME']).then(({ NAME }) => …)` and public values with `Cypress.expose('name')`.

    No ESLint rule for this checkCypress docs
  • HighTest without an assertionLine 16
    it('shows the receipt', () => {

    “shows the receipt” has no .should(), .and(), or expect(), so it passes as long as every command finds its element.

    No ESLint rule for this checkCypress docs
  • MediumCommand chained after an actionLine 7
    cy.get('form > div > div > button').click({ force: true }).should('be.disabled')

    `.click()` can re-render the page, so the `.should()` chained after it may act on a stale element. Start a new chain from `cy.`, e.g. `cy.get(…).click()` then `cy.get(…).should(…)`.

    ESLint: cypress/unsafe-to-chain-commandCypress docs
  • MediumForced action (force: true)Line 7
    cy.get('form > div > div > button').click({ force: true }).should('be.disabled')

    `force: true` skips Cypress's actionability checks, so the action can succeed on an element a user couldn't use: hidden, covered, or disabled.

    ESLint: cypress/no-forceCypress docs
  • MediumBrittle selectorLine 7
    cy.get('form > div > div > button').click({ force: true }).should('be.disabled')

    Position-based or deeply nested selectors break on unrelated layout changes. Add a `data-cy` attribute to the element and select that.

    ESLint: cypress/require-data-selectorsCypress docs
  • Mediumcy.get() chained after another commandLine 9
    cy.get('[data-cy=summary]').get('[data-cy=total]').and('contain', '$32.39')

    `.get()` always searches from the document root, not inside the previous element. Use `.find()` to search within it.

    ESLint: cypress/no-chained-get
  • LowHardcoded environment URLLine 3
    cy.visit('https://staging.example.com/cart')

    A full URL ties the test to one environment. Set `baseUrl` in cypress.config and visit a path, e.g. `cy.visit('/cart')`.

    No ESLint rule for this checkCypress docs
  • LowSelector that isn't a data attributeLine 4
    const payButton = cy.get('.btn-primary')

    Classes, IDs, and tags change for styling reasons. Cypress recommends `data-*` attributes such as `[data-cy=submit]`, which exist only for tests.

    ESLint: cypress/require-data-selectorsCypress docs
  • LowSelector that isn't a data attributeLine 6
    cy.get('input[type="password"]').type('SuperSecret123!')

    Classes, IDs, and tags change for styling reasons. Cypress recommends `data-*` attributes such as `[data-cy=submit]`, which exist only for tests.

    ESLint: cypress/require-data-selectorsCypress docs
  • Low.and() starting an assertionLine 9
    cy.get('[data-cy=summary]').get('[data-cy=total]').and('contain', '$32.39')

    `.and()` reads as a continuation. Start the assertion with `.should()` and use `.and()` only after it.

    ESLint: cypress/no-and
  • LowBranching inside a testLine 11
    if (Cypress.env('SKIP_RECEIPT')) {

    Conditional testing is a common source of flakiness: the page may not have finished rendering when the `if` runs. Make the state deterministic, then assert on it.

    No ESLint rule for this checkCypress docs

Enforce these checks in CI

7 of these checks map to rules in eslint-plugin-cypress, so your pipeline can catch them on every pull request.

// npm install --save-dev eslint eslint-plugin-cypress
// eslint.config.mjs
import pluginCypress from "eslint-plugin-cypress";

export default [
  pluginCypress.configs.recommended,
  {
    files: ["cypress/**/*.cy.{js,ts}"],
    rules: {
      "cypress/no-and": "error",
      "cypress/no-assigning-return-values": "error",
      "cypress/no-chained-get": "error",
      "cypress/no-force": "error",
      "cypress/no-unnecessary-waiting": "error",
      "cypress/require-data-selectors": "error",
      "cypress/unsafe-to-chain-command": "error",
    },
  },
];

What it checks

  • cy.wait() with a number, and commands chained after actions like .click()
  • Cypress commands assigned to variables, and async tests and hooks
  • Tests with no assertion, and screenshots taken before any assertion
  • .only, .skip, cy.pause(), and cy.debug() left in the code
  • force: true, chained .get(), cy.xpath(), and selectors that aren't data attributes
  • Hardcoded passwords and tokens, and Cypress.env(), cy.exec(), and .end(), which Cypress 16 removed

New to the framework, or choosing one? See the same test in Cypress, Playwright, and Selenium. Using Playwright instead? Try the Playwright test reviewer.

How it was checked

Checks that have a rule in eslint-plugin-cypress were calibrated by running that plugin (version 7.0.2) on the same test code. For example, only cy.stub() and cy.spy() may be assigned, and .invoke() is safe to chain from. Where the plugin is narrower than the underlying problem, such as async hooks without a title, the reviewer still flags it but doesn't claim an ESLint rule.

It reads your code as text: comments and strings are masked first, then pattern checks run. It doesn't execute the test or follow custom commands into other files, so treat each finding as a question for review, not a verdict.

Compare with an original

When a spec fails, the quickest way to make it pass is to change what it expects. Compare mode takes the original spec and the updated one, for example after an AI coding agent or a teammate “fixed” it, and lists what a reviewer should look at:

  • .should(), .and(), and expect() assertions that were removed, changed, or reduced to exist or be.visible
  • Changed text in cy.contains(), and removed or renamed tests
  • New .only or .skip, fixed cy.wait(), force: true, timeouts, and retries
  • A new uncaught:exception handler, failOnStatusCode: false, and a removed cy.wait('@alias')

Cypress's own self-healing in cy.prompt() works while the test runs and doesn't rewrite your spec, according to Cypress. Compare mode is for changes that do end up in the file.

How compare mode was checked

We ran it on 26 real spec changes from six commits to cypress-realworld-app, Cypress's own example project. 21 of the changed files reported nothing, including a refactor that split actions out of assertion chains. The other 5 files produced 8 reports, all changed assertions: four real rewrites, such as not.be.visible becoming not.exist, two expect() calls rewritten around the same value, and two where only the source of the expected number moved from Cypress.env() to Cypress.expose(), which a reviewer can dismiss quickly.

Our first version reported 17 changes on the same files. Seven were a false “different element” after the action refactor, and the two rewritten expect() calls each showed up as one removed and one added assertion. That run is how we found and fixed both cases.