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