End to End Testing with Cypress

@avindrafernando

The Testing Pyramid πŸ”Ί

Who Am I?

@avindrafernando

@avindra1

avindrafernando

E2E Testing can be challenging

Flaky Tests

Takes too long

Timing Issues

Photo by Philip VeaterΒ on Unsplash

Why Cypress?

Time Travel and Debugging

Real Time Reloads

Automatic Waiting

Network Traffic Control

Spies

Stubs

Clocks

Bundled Tools

Screenshots and Videos

Cypress Architecture

Cypress Electron App

Our App Server

Browser

Cypress

In-browser tool

Our App

Request

Response

Cypress Electron App

Our App Server

Browser

Cypress

In-browser tool

Our App

Request

Response

XHR Interceptor

npm install cypress --save-dev
  • πŸ“„ cypress.jsonΒ - all Cypress settings

  • πŸ“ cypress/integrationΒ - test files (specs)

  • πŸ“ cypress/fixturesΒ - mock data

  • πŸ“ cypress/pluginsΒ - extending Cypress

  • πŸ“ cypress/supportΒ - shared commands, utilities

npx cypress open
npx cypress run

Let's get started

Our First Test...

/// <reference types="cypress" />
// @ts-check

describe('My first test', () => {
  it('the home page loads', () => {
    cy.visit('localhost:3000')

    // use ("selector", "text") arguments 
    // to "cy.contains"
    cy.contains('h1', 'todos')

    // or can use regular expression
    cy.contains('h1', /^todos$/)

    // also good practice is to use data 
    // attributes specifically for testing
    cy.contains('[data-cy=app-title]', 'todos')
  })
})

Now, let's add items

/// <reference types="cypress" />
describe('Adding items to the todo list', () => {
  it('adds two items', () => {
    cy.visit('localhost:3000')
    cy.get('.new-todo').type('first item{enter}')
    cy.contains('li.todo', 'first item').should('be.visible')
    cy.get('.new-todo').type('second item{enter}')
    cy.contains('li.todo', 'second item').should('be.visible')
  })
})

Refactor Opportunity πŸ’‘

/// <reference types="cypress" />
describe('Adding items to the todo list', () => {
  beforeEach(() => {
    cy.visit('localhost:3000')
  })
  
  it('adds two items', () => {
    cy.get('.new-todo').type('first item{enter}')
    cy.contains('li.todo', 'first item').should('be.visible')
    cy.get('.new-todo').type('second item{enter}')
    cy.contains('li.todo', 'second item').should('be.visible')
  })
})
const addItem = text => {
  cy.get('.new-todo').type(`${text}{enter}`)
}

Refactor Opportunity πŸ’‘

Removing items

it('can delete an item', () => {
  // adds a few items
  addItem('simple')
  addItem('hard')
  // deletes the first item
  cy.contains('li.todo', 'simple')
    .should('exist')
    .find('.destroy')
  // use force: true because we don't want to hover
    .click({ force: true })

  // confirm the deleted item is gone from the dom
  cy.contains('li.todo', 'simple').should('not.exist')
  // confirm the other item still exists
  cy.contains('li.todo', 'hard').should('exist')
})
{
  "baseUrl": "http://localhost:3000",
}

Refactor Opportunity πŸ’‘

beforeEach(() => {
  cy.visit('/')
})

cypress.json

*.spec.js

Let's reset the state

data.json

{
  "todos": [
    {
      "title": "first item",
      "completed": false,
      "id": "8872768047"
    },
    {
      "title": "second item",
      "completed": false,
      "id": "6777843890"
    }
  ]
}
{
  "todos": []
}

Reset using XHR call

describe('reset data using XHR call', () => {
  // you can use separate "beforeEach" 
  // hooks or a single one
  beforeEach(() => {
    cy.request('POST', '/reset', {
      todos: []
    })
  })
  beforeEach(() => {
    cy.visit('/')
  })

  it('adds two items', () => {
    addItem('first item')
    addItem('second item')
    cy.get('li.todo').should('have.length', 2)
  })
})

Reset using writeFile

describe('reset data using cy.writeFile', () => {
  beforeEach(() => {
    const emptyTodos = {
      todos: []
    }
    const str = JSON.stringify(emptyTodos, null, 2) + '\n'
    // file path is relative to the project's root folder
    // where cypress.json is located
    cy.writeFile('todomvc/data.json', str, 'utf8')
    cy.visit('/')
  })

  it('adds two items', () => {
    addItem('first item')
    addItem('second item')
    cy.get('li.todo').should('have.length', 2)
  })
})

Cypress Task

cypress/plugins

module.exports = (on, config) => {
  on('task', {
    resetData(dataToSet = DEFAULT_DATA) {
      if (!dataToSet) {
        dataToSet = DEFAULT_DATA
      }
      const dbFilename = getDbFilename()
      debug('reset data file %s with %o', dbFilename, dataToSet)
      if (!dataToSet) {
        console.error('Cannot save empty object in %s', dbFilename)
        throw new Error('Cannot save empty object in resetData')
      }
      const str = JSON.stringify(dataToSet, null, 2) + '\n'
      fs.writeFileSync(dbFilename, str, 'utf8')

      return null
    },
...

Reset using a task

describe('reset data using a task', () => {
  beforeEach(() => {
    cy.task('resetData')
    cy.visit('/')
  })

  it('adds two items', () => {
    addItem('first item')
    addItem('second item')
    cy.get('li.todo').should('have.length', 2)
  })
})

Async Requests???

No Problem...

Let's wait...

it('starts with zero items (waits)', () => {
  cy.visit('/')
  /* eslint-disable-next-line cypress/no-unnecessary-waiting */
  cy.wait(1000)
  cy.get('li.todo').should('have.length', 0)
})

But, wait...

There is a better way!

Using Spies

it('starts with zero items', () => {
  // start Cypress network server
  // spy on route `GET /todos`
  // THEN visit the page
  cy.intercept('GET', '/todos').as('todos')
  cy.visit('/')
  cy.wait('@todos') // wait for `GET /todos` response
    // inspect the server's response
    .its('response.body')
    .should('have.length', 0)
  // then check the DOM
  // note that we don't have to use 
  // "cy.wait(...).then(...)"
  // because all Cypress commands are flattened 
  // into a single chain automatically. 
  // Thus just write "cy.wait(); cy.get();" naturally
  cy.get('li.todo').should('have.length', 0)
})

Using Stubs

it('starts with zero items (stubbed response)', () => {
  // start Cypress network server
  // spy on route `GET /todos`
  // THEN visit the page
  cy.intercept('GET', '/todos', []).as('todos')
  cy.visit('/')
  cy.wait('@todos') // wait for `GET /todos` response
    // inspect the server's response
    .its('response.body')
    .should('have.length', 0)
  // then check the DOM
  cy.get('li.todo').should('have.length', 0)
})

Stubs using a fixture

it('starts with zero items (fixture)', () => {
  // start Cypress network server
  // stub route `GET /todos`, return data from fixture file
  // THEN visit the page
  cy.intercept('GET', '/todos', { fixture: 'empty-list.json' }).as('todos')
  cy.visit('/')
  cy.wait('@todos') // wait for `GET /todos` response
    // inspect the server's response
    .its('response.body')
    .should('have.length', 0)
  // then check the DOM
  cy.get('li.todo').should('have.length', 0)
})

Posting new item

it('posts new item to the server', () => {
  cy.intercept('POST', '/todos').as('new-item')
  cy.visit('/')
  cy.get('.new-todo').type('test api{enter}')
  cy.wait('@new-item')
    .its('request.body')
    .should('have.contain', {
      title: 'test api',
      completed: false
    })
})

Request

Posting new item

it('posts new item to the server response', () => {
  cy.intercept('POST', '/todos').as('new-item')
  cy.visit('/')
  cy.get('.new-todo').type('test api{enter}')
  cy.wait('@new-item')
    .its('response.body')
    .should('have.contain', {
      title: 'test api',
      completed: false
    })
})

Response

Test loading element

it('shows loading element', () => {
  // delay XHR to "/todos" by a few seconds
  // and respond with an empty list
  cy.intercept(
    {
      method: 'GET',
      pathname: '/todos'
    },
    {
      body: [],
      delayMs: 2000
    }
  ).as('loading')
  cy.visit('/')

  // shows Loading element
  cy.get('.loading').should('be.visible')

  // wait for the network call to complete
  cy.wait('@loading')

  // now the Loading element should go away
  cy.get('.loading').should('not.be.visible')
})

Stats please...

Limitations

Trade-offs

Limitations

  • 🚫 General Automation Tool
  • 🚫 Support for multiple tabs
  • 🚫 Two browsers at a time
  • Each test bound to single origin

Permanent Trade-offs

  • Workarounds for hovering
  • cy.tab() command
  • No native or mobile events support
  • Limited iframe support

Temporary Trade-offs

β€œQuality means doing it right even when no one is looking.”— Henry Ford

@avindrafernando

Taprobane Consulting, LLC