Upgrade Cypress From Version 9 to Version 12

How we have upgraded Cypress from v9.7.0 to v12.7.0 with code examples.

Recently I have made two large-scale transitions from Cypress v9.7.0 to the latest version 12.7.0. The first project was the example tests I used for my Cypress Network Testing Exercises course. You can find the example source code before the transition set up for v9 in the repo bahmutov/fastify-example-tests and after the transition in the repo bahmutov/fastify-example-tests-new. The second transition was for my day job at Mercari US. Altogether, both projects had about 400 spec files with 800 end-to-end tests.

I had several reasons for upgrading:

1
2
TypeError: Cannot read properties of undefined (reading 'isServer')
at TLSWrap.onerror (node:_tls_wrap:411:27)
  • I was sick to my stomach of hitting the error #21428
1
TypeError: ErrorConstructor is not a constructor

There were several obstacles why we postponed upgrading for so long

  • we use a lot of Cypress plugins (almost all plugins described in my course Cypress Plugins!) and some of them did not support Cypress v12 yet. For example, my own cypress-if plugin does not support v12 still. The plugins I could control, like cyclope I could upgrade to make work with Cypress v12.
  • Cypress team changed how cy.log works for some reason
  • upgrade requires time and effort. All code using cy.if and cy.then(cy.log) has to be updated

Strategy

Ok, here is how the transition worked. We first renamed cypress/integration folder to cypress/integration-all. We took a few simple specs and moved them to the old cypress/integration folder. This way we could migrate and run only a few specs at a time, and even work in parallel as a team. We split up and each engineer ported one subfolder at a time

1
2
3
4
5
6
7
8
9
cypress/
integration-all/
feature-b/ ... specs
feature-c/ ... specs
feature-d/ ... specs
feature-e/ ... specs
...
integration/
feature-a/ ... specs

Here I am starting by porting a single cypress/integration/feature-a folder with a few specs. I installed Cypress v12.7.0 and opened it for the very first time. The upgrade wizard moved my plugins file and renamed the cypress/integration folder to cypress/e2e. We kept the original *.js spec pattern.

We have went through the list of plugins and checked if there was a newly released version compatible with Cypress v12. Tip: you can use available-versions to quickly find out all versions of an NPM package.

Code changes

First, read the Cypres migration guides. They cover each major Cypress version. Here are some common code changes we had to do to move from Cypress v9 to v12.

CI changes

We changed our CircleCI and GitHub Actions workflows to new versions that support Cypress v12 and let the CI tell us all failing specs.

1
2
3
4
5
6
7
8
# CircleCI config file
# https://github.com/cypress-io/circleci-orb

# 🔻 Cypress v9
cypress: cypress-io/cypress@1

Cypress v12
cypress: cypress-io/cypress@2
1
2
3
4
5
6
7
8
# GitHub Actions
# https://github.com/cypress-io/github-action

# 🔻 Cypress v9
- uses: cypress-io/github-action@v3

# ✅ Cypress v12
- uses: cypress-io/github-action@v5

Replace cy.then(cy.log)

Previously code cy.then(cy.log) yielded whatever the original subject of cy.then was. In Cypress v10, cy.log started yielding null, "breaking" the subject. The solution was to wrap the original subject passed to cy.then like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 🔻 Cypress v9
cy
.wait(alias)
.its('response.statusCode')
.then(cy.log)
.then((statusCode) => {
...
})

// ✅ Cypress v12
cy
.wait(alias)
.its('response.statusCode')
.then(code => {
cy.log(code)
cy.wrap(code, {log: false})
})
.then((statusCode) => {
...
})

If we just want to print the subject value to the Command Log, we could replace .then(cy.log) with an assertion. The assertion both checks the value and prints it to the Command Log.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ✅ all Cypress versions
cy
.wait(alias)
.its('response.statusCode')
.should('be.a', 'number')
.then((statusCode) => {
...
})

// 🔻 Cypress v9
// load the data from the fixture file "apple.json"
cy.fixture('apple.json')
.then(cy.log)

// ✅ all Cypress versions
// load the data from the fixture file "apple.json"
cy.fixture('apple.json')
.should('be.an', 'object')

Even better was to use A Better Cypress Log Command, for example from cypress-map.

1
2
3
4
5
6
7
8
9
// ✅ Cypress v12
import 'cypress-map'
cy
.wait(alias)
.its('response.statusCode')
.print('response status %d')
.then((statusCode) => {
...
})

Another example of changes due to cy.log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 🔻 Cypress v9
// print the first prices to Command Log
// to make they visible in the video
// because cy.log returns nothing, the original "prices" subject
// is going to be yielded down the command chain
.then((prices) => cy.log('prices ' + Cypress._.take(prices, 10).join(', ')))

// ✅ Cypress v12
// print the first prices to Command Log
// to make they visible in the video
.then((prices) => {
cy.log('prices ' + Cypress._.take(prices, 10).join(', '))
cy.wrap(prices, { log: false })
})

There were even hidden ways for cy.log to trick you. If cy.then callback function returns undefined, the yielded value is the the result of the last internal command. Which caused problems if you had cy.log at the end of cy.then(callback)

1
2
3
4
5
6
// 🔻 Cypress v9
// get the addresses somehow
cy.then((addresses) => {
cy.log(`User has ${addresses.length} address(es)`)
})
.should('have.length.greaterThan', 0)

The above code quietly changes the subject from the addresses array to null in Cypress v12 and had to be rewritten.

1
2
3
4
5
6
7
// ✅ Cypress v12
// get the addresses somehow
cy.then((addresses) => {
cy.log(`User has ${addresses.length} address(es)`)
cy.wrap(addresses, { log: false })
})
.should('have.length.greaterThan', 0)

Disable cy.invoke retries

I said many times that Cypress V12 Is A Big Deal, but cy.invoke switching by default to retries is ... weird. For example, this code only runs once in Cypress v9, but multiple times in v10+ causing problems.

1
2
3
4
// 🔻 Cypress v9
cy.wrap(fetch('/fruit'))
.invoke('json')
.should('have.property', 'fruit')

The trick to disable retries is to put them after or inside cy.then command

1
2
3
4
5
// ✅ Cypress v12
cy.wrap(fetch('/fruit'))
// cannot use cy.invoke as it retries in Cypress v12
.then((res) => res.json())
.should('have.property', 'fruit')

I wish Cypress team added an option to cy.invoke command to skip retries, like .invoke({ retries: false }, 'json') Of course, I got you, buddy. You can use cypress-map cy.invokeOnce

1
2
3
4
5
6
// ✅ Cypress v12
import 'cypress-map'

cy.wrap(fetch('/fruit'))
.invokeOnce('json')
.should('have.property', 'fruit')

Another example where I am using the application window's fetch method (which I can intercept using cy.intercept command, unlike cy.request network calls)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 🔻 Cypress v9
cy.window()
.invoke('fetch', '/fruit')
.invoke('json')

// ✅ Cypress v12
cy.window()
// do not use cy.invoke as it retries in Cypress v12
.then((w) => w.fetch('/fruit'))
.then((res) => res.json())

// ✅ Cypress v12
import 'cypress-map'
cy.window()
.invokeOnce('fetch', '/fruit')
.invokeOnce('json')

Disable cy.as retries

Cypress v12 has changed how cy.as command works. If the previous commands are queries, accessing the aliased value would re-run the queries, which would suddenly show you a different value

1
2
3
4
5
6
7
8
// 🔻 Cypress v9
cy.get('.item')
.invoke('text')
.as('itemName')
// load new item
// sometime later
cy.get('@itemName')
.should('equal', 'old item name')

In Cypress v12, if the .item element changed, you would suddenly see "new item name", even if the value of the alias was "old item name" before. The solution is to save it with the "static" option

1
2
3
4
5
6
7
8
// ✅ Cypress v12
cy.get('.item')
.invoke('text')
.as('itemName', { type: 'static' })
// load new item
// sometime later
cy.get('@itemName')
.should('equal', 'old item name')

Remove cypress-if commands

Removing cypress-if plugin with its cy.if command was very sad. It is a powerful plugin, even if it relies on internals of Cypress command chain implementation.

Existence condition

The element existence assertion is built into Cypress querying commands, like cy.get, cy.find, and cy.contains. If we wanted to conditionally do operations if the element exists or not, we had to disable the built-in assertion using cy.should(Cypress._.noop) assertion and check inside the cy.then(callback)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 🔻 Cypress v9
import 'cypress-if'
cy.get('.enabled-sms')
.if('exists')
.log('User enabled SMS notifications')
.else()
.get('.enable-sms')
.click()

// ✅ Cypress v12
cy.get('.enabled-sms')
.should(Cypress._.noop)
.then($el => {
if ($el.length) {
cy.log('User enabled SMS notifications')
} else {
cy.get('.enable-sms').click()
}
})

Visibility condition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 🔻 Cypress v9
import 'cypress-if'
cy.get('.product')
.if('visible')
.log('have a product')
.then(() => {
// do something with the product
})
.else()
.log('need to pick the product')
.get('.select-product')
.click()

// ✅ Cypress v12
cy.get('.product')
.then($product => {
if (Cypress.dom.isVisible($product)) {
cy.log('have a product')
.then(() => {
// do something with the product
})
} else {
cy.log('need to pick the product')
.get('.select-product')
.click()
}
})

Checked box condition

If a checkbox might be checked or not, and we wanted to have it checked:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 🔻 Cypress v9
import 'cypress-if'
cy.get('#enrolled')
.if('checked')
.log('**already enrolled**')
// the checkbox should be passed into .else()
.else()
.check()
.finally()
.should('be.checked')

// ✅ Cypress v12
cy.get('#enrolled')
.then($el => {
if ($el.is(':checked')) {
cy.log('**already enrolled**')
// yield the element
cy.wrap($el, { log: false })
} else {
cy.wrap($el, { log: false }).check()
}
})
.should('be.checked')

Location pathname

Imagine you are buying an item, but sometimes the system does extra security checks, and redirects you to verify you credit card. We used to handle it quite easily by checking the location pathname after the "Purchase" click.

1
2
3
4
5
6
7
8
9
10
// 🔻 Cypress v9
import 'cypress-if'
cy.contains('Purchase').click()
// try for 5 seconds to see if the URL pathname
// includes the string "/verify_card/"
cy.location('pathname', { timeout: 5000 })
.if('includes', '/verify_card/')
.then(() => {
// enter credit card verification info
})

Without cypress-if the simplest check waits 5 seconds, then checks the URL

1
2
3
4
5
6
7
8
// ✅ Cypress v12
cy.contains('Purchase').click().wait(5000)
cy.location('pathname')
.then(pathname => {
if (pathname.includes('/verify_card/')) {
// enter credit card verification info
}
})

I think this was it. Now smooth sailing with Cypress v12.