Skip to main content

Cypress Testing

When it comes to test heavy visual features, (e.g. fixed navigation based on window scroll event), E2E testing helps a lot.

Cypress Installation

yarn add -D cypress typescript
yarn cypress open

cypress open will initialize the cypress folder structure.

Cypress Configuration

cypress/tsconfig.json:

{
"extends": "../tsconfig.json",
"include": ["global.d.ts", "**/*.ts"],
"exclude": [],
"compilerOptions": {
"strict": true,
"target": "ES6",
"lib": ["ES6", "DOM"],
"types": ["cypress"],
"isolatedModules": false,
"noEmit": true
}
}

cypress/global.d.ts:

/// <reference types="cypress" />

cypress.config.ts:

import { defineConfig } from 'cypress'

export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
},
})

tsconfig.json:

{
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "cypress"]
}

package.json:

{
"scripts": {
"e2e:chrome": "start-server-and-test e2e:prepare http://localhost:3000 cypress:chrome",
"e2e:firefox": "start-server-and-test e2e:prepare http://localhost:3000 cypress:firefox",
"e2e:ui": "start-server-and-test e2e:prepare http://localhost:3000 cypress:open",
"e2e:prepare": "yarn build && yarn serve",
"cypress:chrome": "cypress run --browser chrome",
"cypress:chromium": "cypress run --browser chromium",
"cypress:edge": "cypress run --browser edge",
"cypress:electron": "cypress run",
"cypress:firefox": "cypress run --browser firefox",
"cypress:open": "cypress open --browser electron --e2e"
}
}

.gitignore:

# cypress files
cypress/screenshots
cypress/videos

jest.config.js:

const config = {
testPathIgnorePatterns: ['/node_modules/', '/.next/', '/cypress/'],
}

Basic Cypress Testing

cypress/support/commands.ts:

import '@testing-library/cypress/add-commands'

cypress/e2e/component.cy.ts:

/// <reference types="cypress"/>

describe('component', () => {
it('should work', () => {
cy.visit('/')
cy.get('#onOff')
.should('have.text', 'off')
.click()
.should('have.text', 'on')
})
})

cypress/e2e/payment.cy.ts:

import { v4 as uuid } from 'uuid'

describe('payment', () => {
it('user can make payment', () => {
// Login.
cy.visit('/')
cy.findByRole('textbox', { name: /username/i }).type('sabertaz')
cy.findByLabelText(/password/i).type('secret')
cy.findByRole('checkbox', { name: /remember me/i }).check()
cy.findByRole('button', { name: /sign in/i }).click()

// Check account balance.
let oldBalance
cy.get('[data-test=nav-user-balance]').then(
$balance => (oldBalance = $balance.text())
)

// Click on new button.
cy.findByRole('button', { name: /new/i }).click()

// Search for user.
cy.findByRole('textbox').type('devon becker')
cy.findByText(/devon becker/i).click()

// Add amount and note and click pay.
const paymentAmount = '5.00'
cy.findByPlaceholderText(/amount/i).type(paymentAmount)
const note = uuid()
cy.findByPlaceholderText(/add a note/i).type(note)
cy.findByRole('button', { name: /pay/i }).click()

// Return to transactions.
cy.findByRole('button', { name: /return to transactions/i }).click()

// Go to personal payments.
cy.findByRole('tab', { name: /mine/i }).click()

// Click on payment.
cy.findByText(note).click({ force: true })

// Verify if payment was made.
cy.findByText(`-$${paymentAmount}`).should('be.visible')
cy.findByText(note).should('be.visible')

// Verify if payment amount was deducted.
cy.get('[data-test=nav-user-balance]').then(($balance) => {
const convertedOldBalance = Number.parseFloat(oldBalance.replace(/\$|,/g, ''))
const convertedNewBalance = Number.parseFloat(
$balance.text().replace(/\$|,/g, '')
)
expect(convertedOldBalance - convertedNewBalance).to.equal(
Number.parseFloat(paymentAmount)
)
})
})
})

Cypress Principles

  • Flake resistance and retry-ability: don't wait for fixed time, wait for specific elements (cy.as): cy.get/cy.find/cy.its/cy.should commands will give the page an opportunity to fully load, and then the tests can proceed (Cypress run in browser directly).
  • Asynchronous nature: use cy.then/cy.wrap for async nature of Cypress.

Cypress Commands

Cypress Basic Commands

  • cy.its: get property value on previously yielded subject.
  • cy.invoke: invoke function on previously yielded subject.
cy.wrap(['Wai Yan', 'Yu']).its(1).should('eq', 'Yu')
cy.wrap({ age: 52 }).its('age').should('eq', 52)
cy.wait('@publicTransactions')
.its('response.body.results')
.invoke('slice', 0, 5)

Cypress Action Commands

  • cy.click.
  • cy.dbclick.
  • cy.type.
  • cy.clear.
  • cy.focus.
  • cy.blur.
  • cy.check.
  • cy.uncheck.
  • cy.select.
  • cy.selectFile.
  • cy.submit.
  • cy.trigger.
  • cy.scrollTo.
  • cy.scrollIntoView.

Cypress Network Commands

  • cy.intercept: mock API response.
cy.intercept('GET', '/transactions/public*', {
fixture: 'public-transactions.json',
}).as('mockedPublicTransactions')

cy.wait('@mockedPublicTransactions')

cy.intercept('GET', '/transactions/public*', {
headers: {
'X-Powered-By': 'Express',
'Date': new Date().toString(),
},
})

cy.intercept('POST', '/bankAccounts', (req) => {
const { body } = req
req.continue((res) => {
res.body.data.listBankAccount = []
})
})

cy.intercept('POST', apiGraphQL, (req) => {
const { body } = req

if (
Object.hasOwn(body, 'operationName')
&& body.operationName === 'CreateBankAccount'
) {
req.alias = 'gqlCreateBankAccountMutation'
}
})
  • cy.request: API integration/E2E tests.
Cypress.Commands.add('getAllPosts', () => {
return cy.request('GET', '/api/posts').then((response) => {
return cy.wrap(response.body)
})
})

Cypress.Commands.add('getFirstPost', () => {
return cy.request('GET', '/api/posts').then((response) => {
return cy.wrap(response.body).its(0)
})
})

describe('GET', () => {
it('gets a list of users', () => {
cy.request('GET', '/users').then((response) => {
expect(response.status).to.eq(200)
expect(response.body.results).length.to.be.greaterThan(1)
})
})

it('gets a list of comments', () => {
cy.request('/comments').as('comments')

cy.get('@comments').should((response) => {
expect(response.body).to.have.length(500)
expect(response).to.have.property('headers')
expect(response).to.have.property('duration')
})
})
})

Cypress Custom Command

/// <reference types="cypress" />

declare global {
namespace Cypress {
interface Chainable {
findByRole: (role: string) => Chainable<JQuery<HTMLElement>>
findByTestId: (testId: string) => Chainable<JQuery<HTMLElement>>
getByRole: (role: string) => Chainable<JQuery<HTMLElement>>
getByTestId: (testId: string) => Chainable<JQuery<HTMLElement>>
}
}
}

Cypress.Commands.add(
'findByRole',
{ prevSubject: 'element' },
(subject, role) => {
return cy.wrap(subject, { log: false }).find(`[role="${role}"]`)
}
)

Cypress.Commands.add(
'findByTestId',
{ prevSubject: 'element' },
(subject, testId) => {
return cy.wrap(subject, { log: false }).find(`[data-testid="${testId}"]`)
}
)

Cypress.Commands.add('getByRole', (role) => {
return cy.get(`[role="${role}"]`)
})

Cypress.Commands.add('getByTestId', (testId) => {
return cy.get(`[data-testid="${testId}"]`)
})

Custom command log:

Cypress.Commands.add('take', (input: string) => {
let element: JQuery<HTMLElement> | HTMLElement[]
let count: number

const log = Cypress.log({
autoEnd: false,
consoleProps() {
return {
selector: input,
Yielded: element,
Elements: count,
}
},
displayName: 'take',
name: 'Get by [data-cy] attribute',
})

cy.get(`[data-cy=${input}]`, { log: false }).then(($el) => {
element = Cypress.dom.getElements($el)
count = $el.length
log.set({ $el })
log.snapshot().end()
})

cy.on('fail', (err) => {
log.error(err)
log.end()
throw err
})
})

Cypress Plugin

Setup TypeScript to transpile tests:

import wp from '@cypress/webpack-preprocessor'
// cypress.config.ts
import { defineConfig } from 'cypress'

export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
on(
'file:preprocessor',
wp({
webpackOptions: {
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: { transpileOnly: true },
},
],
},
},
})
)
},
},
})

AXE a11y testing:

// cypress.config.ts
import { defineConfig } from 'cypress'
import fetch from 'undici'

export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
on('task', {
sitemapLocations() {
return fetch(`${config.baseUrl}/sitemap.xml`, {
method: 'GET',
headers: {
'Content-Type': 'application/xml',
},
})
.then(res => res.text())
.then((xml) => {
const locs = [...xml.matchAll(`<loc>(.|\n)*?</loc>`)].map(
([loc]) => loc.replace('<loc>', '').replace('</loc>', '')
)
return locs
})
},
})

return config
},
},
})

// cypress/e2e/smoke.cy.ts
it('should be accessible', () => {
cy.task('sitemapLocations').then((pages) => {
pages.forEach((page) => {
cy.visit(page)
cy.injectAxe()
cy.checkA11y(
{
exclude: ['.article-action'],
},
{
rules: {
'empty-heading': { enabled: false },
'scrollable-region-focusable': { enabled: false },
},
}
)
})
})
})
  • Cypress code coverage plugin.
  • Cypress commands plugin.
  • Cypress events plugin.
  • Cypress accessibility testing plugin.
  • Cypress visual regression testing plugin.

Cypress References