Testing with Cypress.io 12

What you can do with Cypress

  • End-to-End testing in BDD (expect/should) and TDD (assert) assertion styles
it('adds todos', () => {
  cy.visit('https://todo.app.com')
  cy.get('[data-testid="new-todo"]')
    .type('write code{enter}')
    .type('write tests{enter}')
  // confirm the application is showing two items
  cy.get('[data-testid="todos"]').should('have.length', 2)
})
  • Component testing
import TodoList from './components/TodoList'

it('contains the correct number of todos', () => {
  const todos = [
    { text: 'Buy milk', id: 1 },
    { text: 'Learn Component Testing', id: 2 },
  ]

  cy.mount(<TodoList todos={todos} />)
  // the component starts running like a mini web app
  cy.get('[data-testid="todos"]').should('have.length', todos.length)
})
  • API testing
    it('adds a todo', () => {
      cy.request({
        url: '/todos',
        method: 'POST',
        body: {
          title: 'Write REST API',
        },
      })
        .its('body')
        .should('deep.contain', {
          title: 'Write REST API',
          completed: false,
        })
    })

    Cypress features

    • See what happened at each step of testing (time travel)
    • Debug directly using Developer Tools
    • Automatic waiting without using of wait commands or manual async/await calls
    • Verify and control the behavior of functions using Spies, Stubs, and Clocks
    • Easily control, stub, and test edge cases without involving your server
    • Consistent results, because it does not use Selenium or WebDriver
    • View screenshots taken automatically on failure, or videos of your entire test suite
    • Cross browser Testing
    • Parallelize your test suite on Cypress cloud
    • Discover and diagnose unreliable tests using Cypress Cloud’s Flaky test management.

    Important Cypress concepts

    • Cypress is built on top of Mocha, Sinon and Chai.
    • Cypress Test Runner runs all your tests in a real browser installed on your local system
    • Cypress commands don’t do anything at the moment they are invoked, but rather enqueue themselves to be run later in serial order
    • Cypress commands are asynchronous but you should not/can’t use async/await or loops

    Cypress vs. Selenium vs. WebDriver

    Selenium and most other testing tools run outside of the browser and execute remote commands across the network. Cypress does the opposite as it is executed in the same run loop as the application. Behind Cypress is a Node server process which constantly communicates, synchronizes, and perform tasks in accordance with Cypress. This gives us the ability to respond to the application’s events in real time, while at the same time work outside of the browser for tasks that require a higher privilege. Cypress also operates at the network layer by reading and altering web traffic on the fly. This enables Cypress to not only modify everything coming in and out of the browser. Cypress ultimately controls the entire automation process from top to bottom, which puts it in the unique position of being able to understand everything happening in and outside of the browser. Because Cypress is installed locally on your machine, it can additionally tap into the operating system for automation tasks. This makes performing tasks such as taking screenshots, recording videos, general file system operations and network operations possible.

    Your test code can access all the same objects that your application code can.

    Installing Cypress

    Change into your project root folder (package.json must exist) and run

    yarn add cypress -dev

    Verify that it was correctly installed by running Cypress:

    yarn run cypress open

    or in case this makes trouble (it did for me, see below) run that instead:

    node_modules\.bin\cypress open

    For convenience you can add a script to package.json:

    {
      "scripts": {
        "cy:open": "cypress open",
        "cy:run": "cypress run"
      }
    }

    Folder structure

    The first time you run cypress in your project it will create a default folder structure in your project root:

    E2E:
    /cypress.config.ts
    /cypress/fixtures/example.json
    /cypress/support/commands.ts
    /cypress/support/e2e.ts
    
    Component:
    /cypress.config.ts
    /cypress/fixtures/example.json
    /cypress/support/commands.ts
    /cypress/support/component.ts
    /cypress/support/component-index.html
    • Fixtures are external pieces of static data that can be used by your tests. You would typically use them with the cy.fixture() command and most often when you’re stubbing Network Requests.
    • Commands allow you to write common functions, e.g. to login()
    • Support file runs before every single spec file. The names of the support files are fix: component.ts and e2e.ts. This is a great place to put global configuration and behavior that modifies Cypress.

    Asset folders

    The following asset folders are created in certain situations:

    /cypress
      - /downloads (when a test downloads a file)
      - /screenshots (via cy.screenshot() or if test fails)
      - /videos

    You probably want to .gitignore those folders.

    Test isolation vs. caching in session

    When running component tests, Cypress always resets the browser context, that is clearing cookies, localStorage and sessionStorage. Cypress supports enabling or disabling test isolation in end-to-end testing to describe if a suite of tests should run in a clean browser context or not. When enabled, Cypress resets the browser context before each test.

    Cypress allows for browser context to be cached with cy.session(). This means as a user, you only need to perform authentication once for the entirety of your test suite, and restore the saved session between each test. That means you do not have to visit a login page, type in a username and password and wait for the page to load and/or redirect for every test you run. You can accomplish this once with cy.session() and if needed, cy.origin().

    // Caching session when logging in via page visit
    cy.session(name, () => {
      cy.visit('/login')
      cy.get('[data-test=name]').type(name)
      cy.get('[data-test=password]').type('s3cr3t')
      cy.get('form').contains('Log In').click()
      cy.url().should('contain', '/login-successful')
    })
    
    // Caching session when logging in via API
    cy.session(username, () => {
      cy.request({
        method: 'POST',
        url: '/login',
        body: { username, password },
      }).then(({ body }) => {
        window.localStorage.setItem('authToken', body.token)
      })
    })

    Writing E2E tests

    • Tests can be written in .ts, .tsx, .js, .jsx, .coffee and .cjsx
    • You can import or require both npm packages and local relative modules.

    Create a test file mytest.spec.js in my-app/cypress/e2e.

    // mytest.spec.js
    
    describe('My test suite', () => {
      it('should test something', () => {
    
      })
    })

    context() is identical to describe() and specify() is identical to it().

    Test configuration

    You can configure things such a baseUrl, browser, requestTimeout, retries and many more.

    describe(name, config, fn)
    context(name, config, fn)
    it(name, config, fn)
    specify(name, config, fn)

    Querying elements by CSS selectors

    cy.get('.my-selector');
    cy.get('#elementId');
    cy.get(':checkbox')
    // and all other possible CSS selectors

    Adjusting async retry logic timeout

    Cypress wraps all DOM queries with an asynchronous retry-and-timeout logic. That’s why the following will not work:

    // This will not work! Cypress does not return the element synchronously.
    const $cyElement = cy.get('.element')

    Default timeouts

    • 4 seconds for selecting elements
    • 6 seconds for cy.visit() and cy.exec()
    • cy.wait() 5 seconds when waiting for routing alias plus 3 seconds for the server response

    You can define your own timeout if an element needs more time to appear:

    // Give this element 10 seconds to appear
    cy.get('.my-slow-selector', { timeout: 10000 })

    Here is an example with two assertions:

    cy.get('.mobile-nav').should('be.visible').and('contain', 'Home')

    It is important to understand that the default timeout of 4 seconds applies to the command, not the assertions. In other words: Cypress gives a total of 4 seconds to assert that the element .mobile-nav is visible and contains the text Home, and not a total of 4 + 4 + 4 = 12 seconds.

    You can also set a global timeout.

    Access a selected element

    cy.get('#some-link')
      .then(($myElement) => {
        // grab its href property
        const href = $myElement.prop('href')
        // strip out the 'hash' character and everything after it
        return href.replace(/(#.*)/, '')
      })
      .then((href) => {
        // href is now the new subject
        // which we can work with now
      })

    Querying elements by text content

    // Find an element in the document containing the text 'New Post'
    cy.contains('New Post')
    
    // Find an element within '.main' containing the text 'New Post'
    cy.get('.main').contains('New Post')

    Instead of cy.get(selector).should('contain', text) or cy.get(selector).contains(text) chain, it is recommended using cy.contains(selector, text) which is retried automatically as a single command.

    You probably do not want to use this approach if your text changes frequently or you use translations.

    Filter elements

    cy.get('td').filter('.users')

    Interacting with elements

    Cypress will wait until a selected element becomes “actionable”, e.g. waits until is not hidden, not being covered, not being disabled, not being animated.

    cy.get('textarea.post-body').type('This is an excellent post.')
    
    // others:
    .blur()
    .focus()
    .clear()
    .check()
    .uncheck()
    .select()
    .dblclick()
    .rightclick()

    Example of pressing keys (e.g. Enter) into an element:

    cy.get('.new-todo').type('todo A{enter}')

    An events’ coordinates are fired at the center of the element, but most commands enable you to change the position it’s fired to.

    cy.get('button').click({ position: 'topLeft' })

    Debugging interactions

    cy.get('button').debug().click()

    Now open up the dev tools in the test runner and debug from there.

    In the web dev console you have access to subject variable which points to the selected element. You can invoke functions, e.g. subject.text().

    Assertions

    Cypress uses Chai to handle assertions. Here is the full assertion reference.

    Cypress will automatically wait until these assertions pass. This prevents you from having to know or care about the precise moment your elements eventually do reach this state.

    cy.get(':checkbox').should('be.disabled')
    
    // others:
    .should('have.class', 'form-horizontal')
    .should('not.have.value', 'US')

    Assert existence of an element

    .should('exist')
    .should('not.exist')
    .should('be.visible')
    .should('not.be.visible')

    Example of asserting that elements disappear after an action:

    // now Cypress will wait until this
    // <button> is not in the DOM after the click
    cy.get('button.close').click().should('not.exist')
    
    // and now make sure this #modal does not exist in the DOM
    // and automatically wait until it's gone!
    cy.get('#modal').should('not.exist')

    Assert if CSS class or CSS property exists

    .should('have.class', 'my-list-item')
    .should('have.css', 'background-color', 'blue')

    Example of asserting that an object contains a specific path of properties:

    cy.window()
      .its('app.model.todos') // retried
      .should('have.length', 2)

    Assert text content

    .invoke('text')
      .should('contain')
      .should('not.contain')

    Default assertions

    With Cypress, you don’t have to assert to have a useful test. This is because many commands have a built in Default Assertion which offer you a high level of guarantee. For example cy.visit(‘http://somesite.org’) expects the page to send text/html content with a 200 status, or that cy.request() expects that a server provides a response etc.

    A full list of assertion can be found in this reference.

    Chaining multiple assertions

    cy.get('#header a')
      .should('have.class', 'active')
      .and('have.attr', 'href', '/users')

    or use the long way – if you want to change the element in some way prior to making the assertion:

    cy.get('tbody tr:first').should(($tr) => {
      expect($tr).to.have.class('active')
      expect($tr).to.have.attr('href', '/users')
    })

    Here is another advanced example in which we assert that all p-elements have a specific text:

    cy.get('p').should(($p) => {
      // massage our subject from a DOM element
      // into an array of texts from all of the p's
      let texts = $p.map((i, el) => {
        return Cypress.$(el).text()
      })
    
      // jQuery map returns jQuery object
      // and .get() converts this to an array
      texts = texts.get()
    
      // array should have length of 3
      expect(texts).to.have.length(3)
    
      // with this specific content
      expect(texts).to.deep.eq([
        'Some text from first p',
        'More text from second p',
        'And even more text from third p',
      ])
    })

    You have to make sure that the entire function can be executed multiple times without side effects.

    Best practice: Alternate commands and assertions

    it('adds two items', () => {
      cy.visit('/')
    
      cy.get('.new-todo').type('todo A{enter}')
      cy.get('.todo-list li') // command
        .should('have.length', 1) // assertion
        .find('label') // command
        .should('contain', 'todo A') // assertion
    
      cy.get('.new-todo').type('todo B{enter}')
      cy.get('.todo-list li') // command
        .should('have.length', 2) // assertion
        .find('label') // command
        .should('contain', 'todo B') // assertion
    })
    

    Aliases

    Instead of selecting an element by writing selectors such as '.my-selector > p.something' many times you can instead define a short alias for convenience.

    cy.get('.my-selector')
      .as('myElement') // sets the alias
      .click()
    
    /* many more actions */
    
    cy.get('@myElement') // re-queries the DOM as before (only if necessary)
      .click()

    It is recommended that you alias elements as soon as possible instead of further down a chain of commands.

    Aliases when using Mocha hooks

    beforeEach(() => {
      // alias the $btn.text() as 'text'
      cy.get('button').invoke('text').as('text')
    })
    
    it('has access to text', function () {
      this.text // access is synchronously, but cannot use with arrow function
      cy.get('@text') // access is asynchronously and also we can use arrow functions
    })
    

    Triggering events on selected elements / Dynamically generate tests

    You use .trigger():

    describe('if your app uses jQuery', () => {
      ;['mouseover', 'mouseout', 'mouseenter', 'mouseleave'].forEach((event) => {
        it('triggers event: ' + event, () => {
          // if your app uses jQuery, then we can trigger a jQuery
          // event that causes the event callback to fire
          cy.get('#with-jquery')
            .invoke('trigger', event)
            .get('#messages')
            .should('contain', 'the event ' + event + 'was fired')
        })
      })
    })
    

    Fixtures

    beforeEach(() => {
      // alias the users fixtures
      cy.fixture('users.json').as('users')
    })
    
    it('utilize users in some way', function () {
      // use the special '@' syntax to access aliases
      // which avoids the use of 'this'
      cy.get('@users').then((users) => {
        // access the users argument
        const user = users[0]
    
        // make sure the header contains the first
        // user's name
        cy.get('header').should('contain', user.name)
      })
    })
    

    Running Tests

    • Cypress suggests running test files individually by clicking on the spec filename to ensure the best performance.
    • But Cypress also allows you to run all spec files together by clicking the “Run all specs” button. This mode is equivalent to concatenating all spec files together into a single piece of test code.
    • You can also run a subset of all specs by entering a text search filter.

    Watching for changes

    When running in using cypress open, Cypress watches the filesystem for changes to your spec files. Soon after adding or updating a test Cypress will reload it and run all of the tests in that spec file.

    Skipping tests

    Can be used as placeholder, when you have not yet completely written the test. Those tests will have the state pending.

    describe('TodoMVC', () => {
      it('is not written yet')
    
      it.skip('adds 2 todos', function () {
        cy.visit('/')
        cy.get('.new-todo').type('learn testing{enter}').type('be cool{enter}')
        cy.get('.todo-list li').should('have.length', 100)
      })
    
      xit('another test', () => {
        expect(false).to.true
      })
    })
    

    Run in debug mode

    DEBUG=cypress:server:specs npx cypress open
    ## or
    DEBUG=cypress:server:specs npx cypress run

    Conditional Testing

    The only way to do conditional testing on the DOM is if you are 100% sure that the state has “settled” and there is no possible way for it to change.

    In all other cases you can still achieve conditional testing without relying on the DOM. You have to anchor yourself to another piece of truth that is not mutable.

    To write tests for A/B Testing you could program your server that always returns a fixed state (A or B) depending on a parameter that you send along:

    cy.visit('https://app.com?campaign=A')
    
    cy.visit('https://app.com?campaign=B')

    Alternatively, if your server saves the campaign with a session, you could ask your server to tell you which campaign you are on.

    // this sends us the session cookies
    cy.visit('https://app.com')
    
    // assuming this sends us back
    // the campaign information
    cy.request('https://app.com/me')
      .its('body.campaign')
      .then((campaign) => {
        // runs different cypress test code
        // based on the type of campaign
        return campaigns.test(campaign)
      })
    

    Another way to test this is if your server sent the campaign in a session cookie that you could read off.

    cy.visit('https://app.com')
    cy.getCookie('campaign').then((campaign) => {
      return campaigns.test(campaign)
    })

    Another valid strategy would be to embed data directly into the DOM – but do so in a way where this data is always present and query-able. It would have to be present 100% of the time, else this would not work.

    cy.get('html')
      .should('have.attr', 'data-campaign')
      .then((campaign) => {
        return campaigns.test(campaign)
      })

    The official doc has more strategies of conditional testing.

    Environment variables

    Option 1: Setting env var via command line

    E.g. export CYPRESS_MY_VARIABLE="hello" on Linux (must start with CYPRESS_).

    Option 2: Passing as argument

    cypress open --env MY_VARIABLE="hello"

    Option 3: Adding to Cypress.json

    {
      "env": {
        "MY_ENV_VARIABLE" : "hello"
      }
    }

    Option 4: Create cypress.env.json

    {
      "MY_ENV_VARIABLE" : "hello"
    }

    Accessing env var in tests

    const my = Cypress.env('MY_VARIABLE')

    Remember to not commit secret info to your repository.

    About Author

    Mathias Bothe To my job profile

    I am Mathias, born 39 years ago in Heidelberg, Germany. Today I am living in Munich and Stockholm. I am a passionate IT freelancer with more than 15 years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I am founder of bosy.com, creator of the security service platform BosyProtect© and initiator of several other software projects.