End-to-End Testing in JavaScript with Cypress

Introduction

End-to-end test automation is an important part of the development lifecycle of any web based application. Choosing the right tool for you and for your application is arguably even more important.

In this guide, we'll take a look at end-to-end testing using Cypress.

"End-to-end" testing refers to the process of simulating user-experience and user interaction with a service, and testing whether there are any errors in that process.

Why Use Cypress?

Easily the biggest pro of using Cypress is something the developers of Cypress call "Time travel".

It eases the process of debugging by allowing you to view everything that happened in the test in its Command Log and its App Preview. Each step will show the state of the application at the time of execution, allowing you to precisely pinpoint the issue when something goes wrong.

We base a significant portion of their cognitive perception on our sight, and "Time Travel" allows us to intuitively (humanly) hunt for bugs, while still giving us the benefit of automation.

It's also a very natural approach to bug-searching based on the fact that this is a framework focused on end-to-end testing, which means that other than just testing the functionalities, we can actually see what the end user would see.

Some of the other reasons why you might want to use Cypress are:

  • It's not based on Selenium so it does not share the same issues and offers a new perspective. Cypress is built from the ground up.
  • Hyper-focused on end-to-end testing.
  • If you can run it in the browser, you can test it with Cypress.
  • You will only ever have to learn JavaScript.
  • Setup is super easy and lightning fast.
  • It was created with Test-Driven-Development in mind.
  • Plenty of official documentation.
  • You can see every single network request made at the point it was made from the browser, with access to all the data.
  • You can stub any network requests, while also being able to create any network requests (meaning you can use Cypress for API testing as well).
  • Active and transparent developers.

Cypress is built on top of Mocha and Chai, which are both modern and popular BDD and TDD libraries, and actually borrows some of the syntax because of this. If you've worked with these before, you'll notice Cypress hooks being directly borrowed from Mocha.

Why Not Use Cypress?

There is no perfect tool, and by extension - no perfect testing tool. While great, Cypress is not an exception to this rule.

Depending on your personal or project requirements, some of the things listed as pros can turn into cons:

  • Since it doesn't use Selenium and is JavaScript based, you will need to have JavaScript knowledge. Selenium has support for JavaScript, Java, Python, Ruby and C#.
  • Since it is hyper-focused on end-to-end testing, it's not going to be a solution you can apply to all other types of tests (except API testing).
  • It does not (and possibly never will) support all the browsers (you can find the list of supported browsers here) This can be an issue since certain types of clients may request IE, Opera, or Safari support.
  • No mobile testing.
  • Known to be flaky when using direct URL navigation.
  • Can't work with more than one tab.
  • Can't navigate to a different domain URL - This can be a huge con if you have more than one app as a part of your solution, or you need to test something on a third party UI. You will need to keep a separate project for your other application, or rely completely on network requests to fetch data.
  • Relatively new, so it does not have as much community material out there as some older testing tools.
  • Some of the roadmap features appear to have taken a back-seat, for some actions you might commonly have in your application - such as file upload, hover and scroll. You will have to find workarounds.
  • Significant work needed if you want direct database communication or pretty much anything outside of direct browser work. They are planning on releasing back-end adapters for other languages though. This guide will promptly be updated as they are released.

Some of these will never change while some are planned to change. If you want more details on which features will be kept and which won't, their trade-offs page is a great place to start.

Installing And Setting Up Cypress

To make testing Cypress out easy and allow developers to test out all of its features - the Cypress team compiled various demo applications that you can use if you don't have a project fired up and ready to test already.

We'll be using the Kitchensink demo application.

Note: For Windows users, run npm run start:ci:windows to start the application.

Once the application is started, let's install Cypress using npm:

$ npm install cypress --save-dev

Finally, we can boot up the library, using npx or yarn:

$ ./node_modules/.bin/cypress run open # Directly
$ npx cypress open # Using npx
$ yarn run cypress open # Using yarn

If you are using the demo application, you will already have a lot of example specs:

Clicking on any of them (for example actions.specs.js) will launch the runner:

Cypress API and Style

Cypress is built on top of Mocha and Chai and borrows some of the syntax and features of them.

Namely, the most notable borrowed elements are the describe(), context(), it() specify() methods. They are essentially wrappers for actual testing methods used to annotate test groups with labels.

It's worth noting that specify() and it() are synonyms, just as describe() and context(). Depending on what sounds more natural to you, you can use any combination of these.

describe() is used to give context to a set of tests, while it() describes individual tests. Typically, you'll nest them in a structure similar to this:

describe("Element X Testing", () => {
    it("Does Y", () => {
        // Test...
    });
    it("Does Z", () => {
        // Test...
    });
});

This is purely to make it easier for ourselves, as well as other developers to get a quick look at what's going on without having to go through the entire (potentially long) chain of methods used to test something.

Within each test, we'll rely on the Cypress instance (cy) to run various methods, such as visit(), get(), fixture(), etc, as well as chain methods to these results.

The visit() and get() methods, which are generally very commonly used, also assert that the element and visited URL exist, considering them as passed tests if no errors are thrown. They are also the start of each chain, hence, they're known as parent methods.

Similarly to asserting existence, you can check if an element contains() a value.

The exec() method executes a command on the Command-Line Interface, and the request() method sends an HTTP request.

The type() method inputs textual contents into elements that can accept textual contents and click() clicks a selected element.

With just these few methods, you can do quite a lot, and a test set will typically contain most of these:

describe("Testing CRUD Form", () => {
    it("Visits the addition page", () => {
        cy.visit('/addProduct');
    });
    it("Gets the input field and inputs text", () => {
        cy.get('.input-element')
          .type('Product 1');
    });
    it("Clicks the 'Add Product' button", () => {
        cy.contains('Add Product')
          .click();
    });
    it("Checks if X was added correctly", () => {
        cy.get('product-title')
          .should('have.value', 'Product 1');
    });
    it("Runs a CLI Command", () => {
        cy.exec('npm run other-service');
    });
    it("Sends POST HTTP request", () => {
        cy.request('POST', '/host/other-service/updateCustomers', { mail: 'Product 1 is out!' })
          .its('body');
    });
});

The Mocha syntax used within Cypress is very simple, straightforward and intuitive. The usage of describe() and it() blocks allows us to very naturally navigate through the tests and annotate what they do.

The should() method relies on Chai assertions, which are also fairly intuitive.

Finally, when you're ready to run the tests, you can run:

$ cypress run --browser chrome

This command runs all the registered tests, until completion. Let's go ahead and add Cypress to an actual project.

Adding Cypress to a Project

Pick a code editor of your choice, and open up the project root, and navigate yourself to /cypress/integration/examples/actions.specs.js to see the code behind all of the tests it runs.

There are already tons of examples here, but let's create our own spec.js file in a moment, and explore:

  • The configuration (cypress.js) file
  • How to use fixture files
  • How to use commands

The configuration file, dubbed cypress.js will automatically be generated in the project root and by default just contains a placeholder for your project's ID:

{	
   "projectId": "yourId"
}
Free eBook: Git Essentials

Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!

Let's add the baseUrl key, and assign it an appropriate value. Since our app is running on port 8080, under localhost let's point to it:

{
  "projectId": "4b7344",
  "baseUrl": "http://localhost:8080"
}

Now, let's create a new directory under /integration called my_tests and a file called tests.spec.js. You will notice that in Cypress you will already prompt you with the option to run this new file, since it responsively scans the /integration directory for new tests.

Let's go ahead and define a couple of tests in our tests.spec.js file:

/// <reference types="cypress" />

/* In general, it is a good practice to store 
 all your selectors in variables, since they 
 might change in the future */
var email;
var emailSelector = '.action-email';

describe('Email Input', () => {
    /* beforeEach() which will run before every 
    it() test in the file. This is great 
    if you want to perform some common actions 
    before each test */
    beforeEach(() => {
        /* Since we defined `baseUrl` in cypress.json,
        using `/` as the value in `cy.visit()` will navigate to it.
        Adding `commands/actions` will add the value to the `baseUrl`. */
        cy.visit('/commands/actions');
        /* We are reading the example fixture file and assigning its
        value to a global variable so it is accessible in every test */
        cy.fixture('example').then((json)=>{
            email = json.email;
        });
    });

    it('Clicks on Actions, and writes the email from the fixture', () => {
        cy.get(emailSelector)
            .type(email)
            .should('have.value', email);
    });
});

The beforeEach() method is run before each it() method. This means that we can set up some common tasks there to avoid repeating them in each it() call. Here, we've simply navigated to localhost:8080/commands/actions. The visit() method accepts a string (URL) to navigate to, and appends it to the baseUrl defined in the configuration file.

Additionally, we've set up a fixture. Fixtures are just static documents (JSON is a popular format to store data, naturally), which we can use to inject data into our tests. They are also commonly used to stub network requests.

Here, we read the JSON data from the example file, located under cypress/fixtures/example.json, and used the extracted value to assign it to our email variable.

This way, we can use the exemplary email in the tests, rather than working with string literals.

As we've already noted, the get() method retrieves the element with the action-email class. We've already saved this class as the emailSelector variable. Then, we write the email from the example.json file into that element - effectively inputting it.

Finally, we assert that the action was successful via Chai's should() method. Let's go ahead and run this test:

$ cypress run

Which results in:

Configuring Global Variables within Fixtures

If we need to access the emailSelector variable much more regularly than for just these tests - we might want to define it as a global variable. This is a perfect use-case for fixtures again, which we can easily access via cy.fixture().

Let's create a new JSON file under the /fixtures directory, called selectors.js which will contain all of the global-level selectors for our application:

{
 "emailSelector": ".action-email"
}

Note: You could also add it into any of the existing fixture files but, in general, it's better to create new files for the specific data rather than making an all purpose file for everything. This makes organization much easier, and consistent.

Creating Custom Methods

Additionally, if you want to execute the same beforeEach() on multiple specification files - you might want to avoid that redundancy too, by adding it to the commands.js file. Any method added to the commands.js file will be registered as a custom method that you can use via the cy instance. So if we're constantly visiting the commands/actions URL, we might as well create a method, say, navigateToActionsPage() that's globally accessible.

The commands.js file is located under the /support directory:

Cypress.Commands.add('navigateToActionsPage', () => {
    cy.visit('/commands/actions');
})

This way, we can add N commands to run, and just reference them instead of writing them again and again. Let's go back to tests.spec.js and redo our code to remove the cy.visit() call and use the method from the commands.js file:

/// <reference types="cypress" />

var email;
var emailSelector;

describe('Email Input', () => {
    beforeEach(() => {
        // Our method in `commands.js`
        // can now be used from anywhere 
        cy.navigateToActionsPage();
        cy.fixture('example').then((json)=>{
            email = json.email;
        });
        // We can now read the selectors fixture 
        // file and load it into our global variable 
        // to store the selector
        cy.fixture('selectors').then((json)=>{
            emailSelector = json.emailSelector;
        });
    });
    it('Clicks on Actions, and writes the email from fixture', () => {
        cy.get(emailSelector).type(email).should('have.value', email);
    });
});

It might not look like much of a difference now, but having a project where one page has, say, 20 input fields that are prone to change means that having a centralized space to keep selectors is necessary for good code upkeep.

Aliasing an XHR request

An XMLHttpRequest (XHR Request) can be used to send and retrieve data from a webpage, without having to reload it. It was originally built for XML data transfer, but is much more commonly used to send and request JSON data instead, even though the name suggests it's only for XML. This is not an uncommon scenario to see, as many web applications send various requests and display the responses sent to those requests on a web page.

We can alias XHR requests, to test their functionality via Cypress. First, let's add another custom method to our commands.js file so we can access it as a global method and use it in our beforeEach() hooks:

Cypress.Commands.add('navigateToAliasingPage', () => {
    cy.visit('/commands/aliasing');
})

Next, let's create a new file called aliasing_tests.spec.js in our /my_tests directory.

Note: Alternatively, you could also add another piece of code (wrapped inside a describe()) in our existing file. Though, generally, it's good practice to keep one feature in one file, creating a new one when you're testing a different feature.

We'll be making use of the cy.intercept() and cy.wait() methods here, made for asserting network requests and responses. The cy.intercept() method is used to spy and stub network requests and responses, and replaces the cy.route() method. On the other hand, the cy.wait() method is used to wait for a fixed time or until an aliased resource resolves.

We'll be sending an XHR request to the /comments endpoint, awaiting the response and testing it. This is exactly the right use-case for intercept() (to stub the request) and wait() (to wait until the returned resource is resolved).

Let's add a couple of tests to the aliasing_tests.spec.js file:

/// <reference types="cypress" />
context('Aliasing XHR', () => {    
  // Aliasing in beforeEach makes the route aliased in every test in this context    
  beforeEach(() => {        
    // Stub and access any XHR GET request and route to **/comments/*.         
    // The ** and * are wild cards, meaning it could be `/microservice/comments/1`
    // or in our case `https://jsonplaceholder.cypress.io/comments/1`       
    // the `as()` function allows us to later `get()` this route with any valid chainable function
    cy.intercept('GET', '**/comments/*').as('getComment');        
    cy.navigateToAliasingPage();    
  });        
  it('clicks a button and expects a comment', () => {        
    // Clicking this button will create and XHR request that generates a comment        
    cy.get('.network-btn').click()        
    // `wait()` is one of the valid chainable actions where we can use the aliased endpoint
    // `then()` will allow us to access all the elements of the response 
    // for validation whether or not this is available on UI        
    cy.wait('@getComment').then((getCommentResponse) => {            
      // `getCommentResponse` contains all the data from our response. 
      // You can investigate this in the network tab of your browser            
      // Check that the response code is what we expect            
      expect(getCommentResponse.response.statusCode).to.equal(200);            
      // Check that the `response.body` has a parameter named 'email', equal to a certain value
      expect(getCommentResponse.response.body.email).to.equal('[email protected]');            
      // Perform same check but for the `name` parameter            
      expect(getCommentResponse.response.body.name).to.equal('id labore ex et quam laborum');        
    });    
  });
});

Let's go ahead and run this test:

$ cypress run --record --spec "cypress/integration/my_tests/aliasing_tests.spec.js"

Which results in:

Mocking XHR Request Responses

Another very useful feature to note is the fact that you could entirely skip the process of creating the comment from the previous section. You could create your very own mock response by stubbing this network request using cy.intercept(). This is useful when the back-end is not developed yet, so you can mock the response before it's finished, or you simply want to only test this part of the application.

We also have another use for fixture files here. Let's create one called mock_comment.json that'll contain the mocked data of a comment, and add the following JSON contents:

{  
  "postId": 1,  
  "id": 1,  
  "name": "My Name",  
  "email": "[email protected]",  
  "body": "My Comment Body"
}

Now, let's create yet another file, called intercepting_requests.spec.js under the /my_tests directory. Here, we will intercept the same endpoint, but inject our fixture as the response, totally skipping the actual request:

/// <reference types="cypress" />
describe('Intercepting XHR', () => {
  beforeEach(() => {       
    // By adding an object `{fixture: 'mock_comment.json'}` in the `intercept()` call,
    // we are telling cypress to use the JSON file as the response.      
    // It can also be aliased using `.as()`.  
    cy.intercept('GET', '**/comments/*',
                 {fixture: 'mock_comment.json'}).as('getComment');       
    cy.navigateToAliasingPage();    
  });        
  it('Clicks a button and expects a comment', () => {        
    cy.get('.network-btn').click()        
    // There is no need to validate the response now since we mocked it,
    // but there is a need to validate the UI        
    cy.fixture('mock_comment').then((json)=>{           
      // We are accessing the comment directly from `mock_comment.json`
      // and checking that the UI is displaying it in its fullest         
      cy.get('.network-comment').should('have.text', json.body);        
    });    
  });
});

Let's run this test:

$ cypress run --record --spec "cypress/integration/my_tests/intercepting_requests.spec.js"

Which results in:

Conclusion

Cypress is a great emerging testing tool. It's super lightweight and easy to set up, and amazing for TDD, being built on top of Mocha and Chai. It allows you to completely mock your back-end, which is also great for testing before integrating with your back-end, or in case your back-end does not exist yet. It could also help shape the back-end in some cases, as it will outline exactly what the front-end expects.

However, if you're looking for a tool that is super flexible in what it can cover, and you need a large, personalized and customized framework, you might want to stick to Selenium.

Last Updated: October 14th, 2023
Was this article helpful?

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms