Gleb Bahmutov PRO
JavaScript ninja, image processing expert, software quality fanatic
Q&A at Slido.com #beyond-hello
https://lizkeogh.com/2019/07/02/off-the-charts/
+3 degrees Celsius will be the end.
How long have you used Cypress?
Q & A at Slido.com event code #beyond-hello
// cypress/integration/spec.js
// NOT GOING TO WORK
const fs = require('fs')
fs.readFileSync(...)
⛔️ Cannot simply access the file system
retries
does not retry
most powerful
most OS-specific
it('retries reading the JSON file', () => {
cy.get('.new-todo')
.type('todo A{enter}')
.type('todo B{enter}')
.type('todo C{enter}')
.type('todo D{enter}')
cy.readFile('./todomvc/data.json').should(data => {
expect(data).to.have.property('todos')
expect(data.todos).to.have.length(4, '4 saved items')
expect(data.todos[0], 'first item').to.include({
title: 'todo A',
completed: false
})
})
})
it('retries reading the JSON file', () => {
cy.get('.new-todo')
.type('todo A{enter}')
.type('todo B{enter}')
.type('todo C{enter}')
.type('todo D{enter}')
cy.readFile('./todomvc/data.json').should(data => {
expect(data).to.have.property('todos')
expect(data.todos).to.have.length(4, '4 saved items')
expect(data.todos[0], 'first item').to.include({
title: 'todo A',
completed: false
})
})
})
cy.readFile is retried until .should(cb) passes
it('retries reading the JSON file', () => {
cy.get('.new-todo')
.type('todo A{enter}')
.type('todo B{enter}')
.type('todo C{enter}')
.type('todo D{enter}')
cy.readFile('./todomvc/data.json').should(data => {
expect(data).to.have.property('todos')
expect(data.todos).to.have.length(4, '4 saved items')
expect(data.todos[0], 'first item').to.include({
title: 'todo A',
completed: false
})
})
})
fast app
it('retries reading the JSON file', () => {
cy.get('.new-todo')
.type('todo A{enter}')
.type('todo B{enter}')
.type('todo C{enter}')
.type('todo D{enter}')
cy.readFile('./todomvc/data.json').should(data => {
expect(data).to.have.property('todos')
expect(data.todos).to.have.length(4, '4 saved items')
expect(data.todos[0], 'first item').to.include({
title: 'todo A',
completed: false
})
})
})
Text
slow app
How do I reset or query a database from my test?
it('starts with an empty list', () => {
cy.request('/persons').its('body').should('deep.equal', [])
})
Passes every time
Q & A at Slido.com event code #beyond-hello
it('starts with an empty list', () => {
cy.request('/persons').its('body').should('deep.equal', [])
})
Passes every time
it('adds an actor without any movies', () => {
cy.request('POST', '/persons', {
firstName: 'Joe',
lastName: 'Smith',
})
// now there should be 1 actor
cy.request('/persons')
.its('body')
.should('have.length', 1)
.its('0')
.should('include', {
firstName: 'Joe',
lastName: 'Smith',
})
})
fails after the second time
Problems:
// cypress/plugins/index.js
module.exports = (on, config) => {
on('task', {
resetPeopleTable() {
// TODO: clear the table
},
})
}
Tip: connect to DB using same code and logic as the app
// cypress/plugins/index.js
const Knex = require('knex')
const knexConfig = require('../../knexfile')
const { Model } = require('objection')
const knex = Knex(knexConfig.development)
Model.knex(knex)
const Person = require('../../models/Person')
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
on('task', {
resetPeopleTable() {
console.log('reset People table')
return Person.query().truncate()
},
})
}
Application code
// cypress/plugins/index.js
const Knex = require('knex')
const knexConfig = require('../../knexfile')
const { Model } = require('objection')
const knex = Knex(knexConfig.development)
Model.knex(knex)
const Person = require('../../models/Person')
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
on('task', {
resetPeopleTable() {
console.log('reset People table')
return Person.query().truncate()
},
})
}
$ node -v
v12.14.1
$ npm install
...
$ ls node_modules/sqlite3/lib/binding
node-v72-darwin-x64
Database module installed native DB binding for Node v12.14.1
$ npx cypress version
Cypress package version: 6.2.1
Cypress binary version: 6.2.1
Electron version: 11.1.1
Bundled Node version: 12.18.3
By default plugins file runs using the Node bundled with Cypress
Solution: set Cypress to use the system Node to run the plugins file
// cypress/plugins/index.js
const Knex = require('knex')
const knexConfig = require('../../knexfile')
const { Model } = require('objection')
const knex = Knex(knexConfig.development)
Model.knex(knex)
const Person = require('../../models/Person')
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
on('task', {
resetPeopleTable() {
console.log('reset People table')
return Person.query().truncate()
},
})
}
terminal
// api.js
router.get('/', ...)
router.post('/persons', ...)
router.get('/persons', ...)
router.patch('/persons/:id', ...)
router.delete('/persons/:id', ...)
...
REST API does not have a method "GET /persons/:id" 🧐
Q & A at Slido.com event code #beyond-hello
it('updates Person information', () => {
cy.request('POST', '/persons', {
firstName: 'Joe',
lastName: 'Smith',
})
.its('body.id')
.then((id) => {
expect(id, 'created id').to.be.a('number')
cy.request({
method: 'PATCH',
url: `/persons/${id}`,
body: {
lastName: 'Pesci',
},
})
.its('body')
.should('deep.equal', {
success: true,
})
// how to check the full record with id: 1?
})
})
⛔️ Cannot add more methods to the REST API
✅ Can query the database using the cy.task
const Person = require('../../models/Person')
module.exports = (on, config) => {
on('task', {
resetPeopleTable() {
console.log('reset People table')
return Person.query().truncate()
},
findPerson(id) {
console.log('looking for person with id %d', id)
return Person.query().findById(id)
},
})
}
⛔️ Cannot add more methods to the REST API
✅ Can query the database using the cy.task
// how to check the full record with id: 1?
cy.task('findPerson', id)
⛔️ Cannot add more methods to the REST API
✅ Can query the database using the cy.task
// how to check the full record with id: 1?
cy.task('findPerson', id).should('include', {
id,
firstName: 'Joe',
lastName: 'Pesci',
})
// ⛔️ cannot pass multiple arguments to the task
cy.task('addNumbers', 2, 3)
// ✅ combine arguments into an options object
cy.task('addNumbers', {a: 2, b: 3})
on('task', {
addNumbers (opts) {
const {a, b} = opts
return a + b
}
})
// ✅ to use modern syntax
on('task', {
async findPerson (...) {
const p = await Person.query()...
const t = await p.children()...
return t.name
}
})
Make sure to return a Promise or null to let the Cypress know the code is really finished
// ✅ to use modern syntax
on('task', {
async findPerson(id) {
console.log('looking for person with id %d', id)
const p = await Person.query().findById(id)
return p || null
}
})
avoids return undefined if not found
on('task', {
findPerson(id) {
console.log('looking for person with id %d', id)
return Person.query().findById(id)
}
})
on('task', {
findPerson(id) {
if (typeof id !== 'number') {
throw new Error('Invalid person id')
}
console.log('looking for person with id %d', id)
return Person.query().findById(id)
}
})
let's validate the ID before using it
const { expect } = require('chai')
on('task', {
findPerson(id) {
expect(id, 'valid id').to.be.a('number').above(0)
console.log('looking for person with id %d', id)
return Person.query().findById(id)
}
})
$ npm i -D chai
+ chai@4.2.0
Because Cypress comes with bundled Chai assertions, let's use them inside plugins too
You need to write your own retry logic inside cy.task
$ npm i -D start-server-and-test
{
"scripts": {
"migrate": "knex migrate:latest",
"start": "npm run migrate && node app",
"cy:open": "cypress open",
"cy:run": "cypress run",
"dev": "start-test 8641 cy:open",
"local": "start-test 8641 cy:run"
}
}
App commands
Cypress commands
local TDD
local CI run
// cypress/integration/matt-damon-spec.js
describe('Matt Damon', () => {
it('has family', () => {
// the database already has the initial family for Matt Damon
cy.request('/persons').its('body').should('have.length', 3)
})
})
How to set up a complex database state?
Q & A at Slido.com event code #beyond-hello
Cypress v6.2.0+
// cypress/plugins/index.js
const { insertPersons } = require('../../utils')
module.exports = (on, config) => {
on('before:spec', async (spec) => {
console.log('before:spec %s', spec.name)
if (spec.name === 'matt-damon-spec.js') {
const mattDamonFamily = require('../fixtures/matt-damon.json')
await Person.query().truncate()
await insertPersons(mattDamonFamily)
}
})
}
Instead of setting DB to empty and building up state
// cypress/support/index.js
before(() => {
if (Cypress.config('isInteractive')) {
cy.task('beforeSpec', Cypress.spec)
}
})
true for "cypress open"
{ name, path, ... }
// cypress/plugins/index.js
const onBeforeSpec = async (spec) => {
console.log('before:spec %s', spec.name)
if (spec.name === 'matt-damon-spec.js') {
...
}
return null
}
module.exports = (on, config) => {
on('before:spec', onBeforeSpec) // for "cypress run"
on('task', {
beforeSpec: onBeforeSpec // for "cypress open"
}
}
Code executes in both "cypress run" and "cypress open" modes
// api.js production code
const { insertPersons } = require('./utils')
router.post('/persons', async (ctx) => {
const insertedGraph = await insertPersons(ctx.request.body)
ctx.body = insertedGraph
})
api code
// cypress/plugins/index.js
const { insertPersons } = require('../../utils')
on('task', {
insertPeople (people) {
return insertPersons(people)
}
})
test plugins code
the same database code
Q & A at Slido.com event code #beyond-hello
// cypress/plugins/index.js
import { toVintageVideo } from "../../code-video"
module.exports = (on, config) => {
on("after:spec", (spec, results) => {
if (!results.video) {
// nothing to process
return;
}
return toVintageVideo(results.video);
});
}
Q & A at Slido.com event code #beyond-hello
context('navigator.battery', () => {
it('shows battery status of 50%', function () {
cy.visit('/', {
onBeforeLoad (win) {
// mock "navigator.battery" property
// returning mock charge object
win.navigator.battery = {
level: 0.5,
charging: false,
chargingTime: Infinity,
dischargingTime: 3600, // seconds
addEventListener: () => {}
}
}
})
// now we can assert actual text - we are charged at 50%
cy.get('.battery-percentage')
.should('be.visible')
.and('have.text', '50%')
// not charging means running on battery
cy.contains('.battery-status', 'Battery').should('be.visible')
// and has enough juice for 1 hour
cy.contains('.battery-remaining', '1:00').should('be.visible')
})
})
<a href="/about.html" target="_blank">About</a>
it('loads the about page', () => {
cy.visit('index.html')
cy.get('a').should($a => {
expect($a.attr('href'), 'href').to.equal('/about.html')
expect($a.attr('target'), 'target').to.equal('_blank')
})
})
it('loads the about page', () => {
cy.visit('index.html')
cy.get('a').should($a => {
expect($a.attr('href'), 'href').to.equal('/about.html')
expect($a.attr('target'), 'target').to.equal('_blank')
$a.attr('target', '_self')
}).click()
cy.location('pathname').should('equal', '/about.html')
})
<a href="/about.html" target="_blank">About</a>
<script>
document.querySelector('a').addEventListener('click', (e) => {
e.preventDefault()
window.open('/about.html')
})
</script>
it('opens the about page', () => {
cy.visit('index.html')
cy.window().then(win => {
cy.stub(win, 'open').as('open')
})
cy.get('a').click()
cy.get('@open').should('have.been.calledOnceWithExactly', '/about.html')
})
it('opens the about page', () => {
cy.visit('index.html')
cy.window().then(win => {
cy.stub(win, 'open').callsFake((url, target) => {
expect(target).to.be.undefined
return win.open.wrappedMethod.call(win, url, '_self')
}).as('open')
})
cy.get('a').click()
cy.get('@open').should('have.been.calledOnceWithExactly', '/about.html')
})
cy.visit('index.html', {
onBeforeLoad (win) {
delete win.navigator.__proto__.serviceWorker
}
})
it('works without SW', () => {
cy.on('window:before:load', (win) => {
delete win.navigator.__proto__.serviceWorker
})
cy.visit('index.html')
})
Cypress.on('window:before:load', (win) => {
delete win.navigator.__proto__.serviceWorker
})
it('works without SW', () => {
cy.visit('index.html')
})
// cypress/support/index.js
Cypress.on('window:before:load', (win) => {
delete win.navigator.__proto__.serviceWorker
})
// cypress/integration/spec.js
it('works without SW', () => {
cy.visit('index.html')
})
<script src="cypress/support/index.js"></script>
<script src="cypress/integration/spec.js"></script>
// positive
cy.get('.todo-item')
.should('have.length', 2)
.and('have.class', 'completed')
// negative
cy.contains('first todo').should('not.have.class', 'completed')
cy.get('#loading').should('not.be.visible')
it('hides the loading element', () => {
cy.visit('/')
cy.get('.loading').should('not.be.visible')
})
Red 🚩: negative assertion passes
before the XHR
it('uses negative assertion and passes for the wrong reason', () => {
cy.visit('/?delay=3000')
cy.get('.loading').should('not.be.visible')
})
Negative assertions passes even before the Ajax request starts
it('use positive then negative assertion (flakey)', () => {
cy.visit('/?delay=3000')
// first, make sure the loading indicator shows up
cy.get('.loading').should('be.visible')
// then assert it goes away (negative assertion)
cy.get('.loading').should('not.be.visible')
})
it('slows down the network response (works)', () => {
cy.intercept('/todos', {
body: [],
delayMs: 5000
})
cy.visit('/?delay=3000')
// first, make sure the loading indicator shows up
cy.get('.loading').should('be.visible')
// then assert it goes away (negative assertion)
cy.get('.loading').should('not.be.visible')
})
Not recommended: installing Cypress globally
Problem: every Cypress version takes 500MB
Solution: trim unused Cypress versions
$ npx cypress cache list --size
┌─────────┬─────────────┬─────────┐
│ version │ last used │ size │
├─────────┼─────────────┼─────────┤
│ 6.1.0 │ 11 days ago │ 516.2MB │
├─────────┼─────────────┼─────────┤
│ 6.2.0 │ 13 days ago │ 516.1MB │
├─────────┼─────────────┼─────────┤
│ 6.2.1 │ 4 days ago │ 515.4MB │
└─────────┴─────────────┴─────────┘
$ npx cypress cache prune
"$schema": "https://on.cypress.io/cypress.schema.json"
/// <reference types="cypress" />
Every command has its documentation at https://on.cypress.io/<command>
https://on.cypress.io/type
https://on.cypress.io/click
Almost every concept has a short link https://on.cypress.io/<concept>
https://on.cypress.io/ci
https://on.cypress.io/docker
(how to open a good GitHub issue)
(how to open a good GitHub issue)
(under development https://github.com/cypress-io/cypress/pull/9542)
(under development https://github.com/cypress-io/cypress/pull/9542)
super low GitHub issue number = it was hard to do, but the users really want it
By Gleb Bahmutov
In this presentation, Gleb Bahmutov, a Distinguished Engineer at Cypress will show how to write realistic Cypress tests beyond a simple "Hello World" test. We will look at Node vs Browser boundary, cy.task, setting up the database state, stubbing browser APIs, documentation, GitHub issues, Cypress Studio, and other advanced topics. Everyone who is just starting with Cypress or is an advanced user will benefit from attending this free meetup. Video at https://www.youtube.com/watch?v=Q5djbLL1Pjg
JavaScript ninja, image processing expert, software quality fanatic