Skill 127 · Cypress Flake Fixer
Subchapter 127.1
references/anti-patterns.mdMarkdown9 KBView on GitHub
One section per scanner rule. Each shows the failing shape, why it flakes, and the replacement.
// Before
cy.get('[data-cy=save]').click()
cy.wait(3000)
cy.get('[data-cy=toast]').should('contain', 'Saved')
// After: the wait was covering a request
cy.intercept('PUT', '/api/profile').as('saveProfile')
cy.get('[data-cy=save]').click()
cy.wait('@saveProfile').its('response.statusCode').should('eq', 200)
cy.get('[data-cy=toast]').should('contain', 'Saved')The delay is either too short on a slow machine (flake) or too long everywhere (slow suite). When the wait covers a timer in the app, fake the clock:
cy.clock()
cy.visit('/search')
cy.get('[data-cy=query]').type('shoes')
cy.tick(300) // the debounce interval
cy.get('[data-cy=result]').should('have.length.greaterThan', 0)cy.clock() must run before cy.visit() to capture timers the app creates while loading.
// Before
const row = cy.get('[data-cy=row]').first()
row.click()
row.should('have.class', 'selected')
// After
cy.get('[data-cy=row]').first().as('row')
cy.get('@row').click()
cy.get('@row').should('have.class', 'selected')A command returns a chainable, not an element. Reusing the variable chains new commands onto a subject captured once. An alias re-runs the query each time it is read.
To use a value later in the test, nest or alias the value:
cy.get('[data-cy=order-id]').invoke('text').as('orderId')
cy.get('@orderId').then((orderId) => {
cy.visit(`/orders/${orderId}`)
})cy.stub() and cy.spy() are synchronous and may be assigned.
// Before
it('loads the user', async () => {
const user = await cy.request('/api/me')
expect(user.body.name).to.eq('Ada')
})
// After
it('loads the user', () => {
cy.request('/api/me').its('body.name').should('eq', 'Ada')
})Commands are not promises. await resolves at the wrong time or never, and an async test function returns a promise that Cypress races against its own queue. Awaiting a real promise inside cy.then() or cy.wrap(promise) is fine.
// Before
cy.get('[data-cy=submit]').click({ force: true })
// After: the button was covered by a cookie banner
cy.get('[data-cy=cookie-accept]').click()
cy.get('[data-cy=cookie-banner]').should('not.exist')
cy.get('[data-cy=submit]').click()Read the actionability error Cypress printed before force was added. It names the cause:
| Error says | Usual cause | Fix |
|---|---|---|
| is being covered by another element | Overlay, sticky header, banner, toast | Dismiss it, or scroll with scrollIntoView() and assert position |
| is not visible | Collapsed menu, display: none, zero size | Open the parent first |
| is disabled | Form not yet valid, request in flight | Assert .should('be.enabled') before acting |
| is animating | Transition still running | Assert the final state; do not disable the check globally |
force: true is legitimate for hidden native inputs behind a styled control, such as a custom file input or checkbox. Leave those and add a comment.
// Before
cy.get('[data-cy=todo]').first().click().find('[data-cy=delete]').click()
// After
cy.get('[data-cy=todo]').first().click()
cy.get('[data-cy=todo]').first().find('[data-cy=delete]').click()An action runs once. If it triggers a re-render, the subject passed down the chain is the old element, and the next command fails with a detached-DOM error or acts on a node that is no longer on screen. A new cy.get() re-queries.
Chaining is fine when the element cannot be replaced: cy.get(input).focus().clear().type('x') on a plain input.
// Before
cy.get('[data-cy=count]').then(($el) => {
expect(Number($el.text())).to.eq(3)
})
// After: simple cases
cy.get('[data-cy=count]').should('have.text', '3')
// After: when the value needs processing
cy.get('[data-cy=count]').should(($el) => {
expect(Number($el.text())).to.eq(3)
}).then() runs its callback once with whatever the element contained at that instant. .should(callback) re-runs the query and the callback until it stops throwing. Do not call cy commands inside a .should() callback.
.then() is correct after one-shot commands such as cy.request(), cy.task(), cy.wait('@alias'), and cy.env().
// Before
cy.get('body').then(($body) => {
if ($body.find('[data-cy=promo-modal]').length) {
cy.get('[data-cy=promo-close]').click()
}
})
// After: control the condition
cy.intercept('GET', '/api/promotions', { body: [] })
cy.visit('/')
cy.get('[data-cy=promo-modal]').should('not.exist')The if runs once, at an arbitrary moment. If the modal renders 50 ms later, the test continues with it open. Remove the uncertainty at its source: stub the response, seed the data, set the cookie or feature flag that decides the branch.
Remove .only, cy.pause(), and cy.debug() before committing. A committed .only silently disables every other test in the spec.
// Before
afterEach(() => { cy.task('db:reset') })
// After
beforeEach(() => { cy.task('db:reset') })An after hook does not run when the run is cancelled, the browser crashes, or a developer refreshes the runner. The next run then starts from dirty state. Resetting first also leaves the failing state in place for debugging.
Set baseUrl in cypress.config and call cy.visit('/login'). Without it Cypress loads on a random localhost port and reloads when the first cy.visit() runs, and the same spec cannot run against another environment.
// cypress/support/commands.js
Cypress.Commands.add('login', (email) => {
cy.session(['login', email], () => {
cy.env(['userPassword']).then(({ userPassword }) => {
cy.request('POST', '/api/login', { email, password: userPassword })
})
}, {
validate() {
cy.request('/api/me').its('status').should('eq', 200)
},
})
})
// in specs
beforeEach(() => {
cy.login('ada@example.com')
cy.visit('/dashboard')
})id array, never the password.cy.session(); call cy.visit().Cypress.env('userPassword') instead of cy.env().// Before
cy.get('ul.results > li:nth-child(2) .btn-primary').click()
// After
cy.get('[data-cy=result]').eq(1).find('[data-cy=open]').click()
// or, when the row is identified by content
cy.contains('[data-cy=result]', 'Invoice 1042').find('[data-cy=open]').click()Class names change with styling work and positions change with sorting and data. Add data-cy attributes to the application when they are missing; that is a legitimate application change for a test fix.
// Before
cy.get('[data-cy=save]').click()
cy.get('[data-cy=spinner]').should('not.exist')
cy.get('[data-cy=status]').should('have.text', 'Saved')
// After
cy.get('[data-cy=save]').click()
cy.get('[data-cy=status]').should('have.text', 'Saved')should('not.exist') passes immediately if the spinner has not rendered yet. The assertion on the final state already waits for the right moment. Keep a negative assertion only after a positive one has proved the page reached the expected state.
cy.wait(TIMEOUT) with a numeric constant is CY001 behind a name. cy.wait(alias) where the variable holds '@orders' is fine. Check the declaration.
cy.intercept() and the action or cy.visit() that triggers the request..only. If it fails alone, an earlier test was setting state for it.cy.clock(new Date(2025, 0, 15)).blockHosts config option.viewportWidth and viewportHeight in the config.