All Articles

E2E tests with Cypress - Beyond the Basics

Cypress allows full-stack end-to-end testing with ease. The API is well documented and the core concepts are totally straightforward. Still, I’ve found myself having to deal with some issues and strategies that are documented here and there. This post is a summary of the most common ones.

Capture fetch requests

One of the trade-offs of Cypress is that it only captures XHRs (not fetch).

If you have a fetch request in your application (I am sure you do), no request would get captured without the workaround below.

cy
  .visit('/some-url-that-fetches-data', {
    onBeforeLoad: (win) => {
      win.fetch = null
    }
  })

Ship a fetch Polyfill

The workaround code above removes fetch from the window object in the global context of the tested application. Since you still have fetch requests present in your application code, you have to ship a polyfill in your application bundle that will detect that fetch is missing in window (because you’ve enforced it in your cypress test) and will mutate the global context (window) with a fetch that uses XHR call underneath. (if (!window.fetch) { window.fetch = functionThatUsesXHR; ... })

whatwg-fetch does exactly that.

React example

// index.js
require('whatwg-fetch');

Unless you’d want to run your tests against production, you wouldn’t want to ship that polyfill in your production bundle. You condition that using an environment variable.

// index.js
if (process.env.REACT_APP_ENV === 'e2e') {
  require('whatwg-fetch');
}

And have a specific build that sets the variable for E2E:

  "scripts": {
    "build:e2e": "REACT_APP_ENV=e2e react-app build",
  }

Overwrite cy.visit()

To avoid adding the workaround to all your page visits, you can overwrite the visit command.

Cypress.Commands.overwrite('visit', (visit, url) => {
  return visit(url, {
    onBeforeLoad: (win) => {
      win.fetch = null
    }
  });
});

Decide your testing Strategy

When your application interacts with a backend, you have to choose one of this two strategies to deal with the outgoing requests:

  • Interact with the real backend
  • Intercept request to your real backend and return mock responses

The latter is faster, it does not need the backend to be seeded with data and it gives you full control of request and responses. However, this is not real full end-to-end testing and you’re never sure if the mocks match the real backend responses.

If you go with interacting with the real backend you can use cypress tasks to seed/clean your backend data before every test.

The general rule is that result of a test should not depend on the test position in the test suite.

Authenticate using 3rd Api (Cross-origin domain)

If you use a third party authentication provider (google, auth0…) to authenticate your users then you will probably deal with the cross-origin domain issue.

Basically Cypress (because of a browser security spec) does not let you control a different origin from the one you initially cy.visit()ed.

You just can’t fill google authentication form inputs with credentials and you would agree that it makes no sense to do so because you have no idea on how the provider’s html is structured and how it could change over time, making your tests eventually fail.

The only solution you have is to use the provider’s authentication api to authenticate your user, by making an async request to your auth provider with a server apikey for example to authenticate a specific user. You can create a specific user for that with different roles depending on what you are willing to test.

Now if your application is using a private company SSO that do does not offer an authentication API, I am very sad for you :(, you’d have to eitheir:

  • Disable the browser security flag so that Cypress can handle redirects.
  • Authenticate the user using Pupeeter for example as a task and run the test using the token received by pupeeter.

Something like:

on("task", {
    authenticate: (args) => {
      return (async () => {
        const browser = await puppeteer.launch({ headless: true })
        const page = await browser.newPage()
        await page.goto('https://<your site with login')
        // Authenticate user here
        // ...
        let cookies = await page.cookies();
        cookie = cookies.find(o => o.name === 'AUTH_COOKIE_FROM_PROVIDER')
        return cookie;
      })();
    }
  });    
describe('Your test suite', () => {
  beforeEach(() => {
    cy.task("authenticate")   
    .then(cookie => {
      cy.setCookie(
          cookie.name, 
          cookie.value, 
          { domain: '<your domain>' }
      );
    });
  });
}

Run your tests in CI

The whole thing about E2E tests is that they need to run on CI (Travis, GitLab CI, CircleCI…)

Deploying the develop branch in a staging environment and then running your tests against that environment is wrong because well you’ve already deployed!, QA guys might do their tests on a broken environment, unless it is a temporary environment dedicated to cypress automated tests.

The pipeline workflow should be the following:

  • Build the application (ship the fetch polyfill)
  • Serve the application inside the CI (using serve, http-server…)
  • Run your tests against the served application (CYPRESS_baseUrl or cypress.json)
"scripts": {
  "build:e2e": "REACT_APP_ENV=e2e react-app build",
  "e2e": "CYPRESS_baseUrl=http://localhost:5000 start-server-and-test 'serve -s build' http://localhost:5000 cypress:run"
}

start-server-and-test used above is a tool that executes the command serve -s build, waits until it is ready on http://localhost:5000 then runs cypress:run which runs cypress tests against the url set to CYPRESS_baseUrl environment variable.

An (incomplete) sample of CircleCI pipeline configuration

build:
    docker:
      - image: cypress/base:10
    working_directory: ~/repo
    steps:
      - checkout
      - run: yarn install
      - run: yarn lint
      - run: yarn test
      - run: yarn build:e2e
      - run: yarn e2e

Use a robust selector

Avoid using classes or ids when targeting elements in a page because they’re coupled to CSS selectors and these are traditionally used for styling and are subject to be changed at any time.

<Button label="Submit" data-cy="login-submit" />
// or
<Button label="Submit" data-testid="login-submit" />

test(() => {
  cy.get('[data-cy="login-submit"]')
    .should('have.text', 'Submit')
})

The choice of the selector name has to be thought from a user perspective rather than a styling/structuring perspecting.

With a custom command

Cypress.Commands.overwrite('get', (get, selector) => 
  return selector.startsWith('d:')
    ? get(`[data-testid="${selector.substr(2)}"]`)
    : get(selector);
);

We can use a simpler syntax

cy.get('d:submit-button').click();

Use Cypress Testing Library

If you want to stay consistent with the way you query elements in your unit tests, you can use Cypress Testing Library

cy.queryByLabelText('Submit').should('exist')

Cypress chain of commands

Cypress manages a Promise chain on our behalf. Cypress commands are asynchronous and get queued for execution at a later time. Cypress commands don’t do anything at the moment they are invoked, but rather enqueue themselves to be run later.

it('submits the form when.....', function() {
  cy.visit('/') // Nothing happens, command is chained
  cy.get('[data-cy="submit"]') // Nothing happens, command is chained
    .click()  // Nothing happens, command is chained
})

When test function has finished executing, all commands have been queed and Cypress will run them in order.

Because of that you cannot assign the commands call to a variable.

// BAD, This won't work.
it('submits the form when.....', function() {
  const button = cy.get('[data-cy="submit"]')
  button.click();
})

If you don’t want to repeat yourself, use aliases.

That’s all I’ve got!