At Twilio SendGrid, we’ve written hundreds of Cypress end-to-end (E2E) tests and continue to write more as new features are released across different web applications and teams. These tests cover the whole stack, verifying that the most common  use cases a customer would go through still work after pushing new code changes in our applications. 

If you would like to first take a step back and read more about how to think about E2E testing in general, feel free to check out this blog post and come back to this once you’re ready. This blog post doesn’t require you to be an expert with E2E tests, but it helps to get in the right frame of mind as you’ll see why we did things a certain way in our tests. If you’re looking for a more step-by-step tutorial introducing you to Cypress tests, we recommend checking out the Cypress docs. In this blog post, we assume you may have seen or written many Cypress tests before and are curious to see how others write Cypress tests for their own applications.

After writing plenty of Cypress tests, you will start to notice yourself using similar Cypress functions, assertions, and patterns to accomplish what you need. We will show you the most common parts and strategies we have used or done before with Cypress to write tests against separate environments such as dev or staging. We hope this 1,000 foot overview of how we write Cypress tests gives you ideas to compare against your own and helps you improve the way you approach Cypress tests.

Outline:

  1. Cypress API roundup
  2. Interacting with elements
  3. Asserting on elements
  4. Dealing with APIs and services
  5. Making HTTP requests with cy.request(…)
  6. Creating reusable plugins with cy.task()
  7. Mocking network requests with cy.server() and cy.route()
  8. Custom commands
  9. About page objects
  10. Choosing not to run client-side code with window.Cypress checks
  11. Dealing with iframes
  12. Standardizing across test environments

Cypress API Roundup

Let’s start by going through the parts we’ve most commonly used with the Cypress API.

Selecting elements

There are many ways to select DOM elements, but you can accomplish most of what you need to do through these Cypress commands and you can usually chain more actions and assertions after these.

  • Getting elements based on some CSS selector with cy.get(“[data-hook=’someSelector’]”) or cy.find(“.selector”).
  • Selecting elements based on some text such as cy.contains(“someText”) or getting an element with a certain selector that contains some text such as cy.contains(“.selector”, “someText”).
  • Getting a parent element to look “within,” so all your future queries will be scoped to the parent’s children such as cy.get(“.selector”).within(() => { cy.get(“.child”) }).
  • Finding a list of elements and looking through “each” one to perform more queries and assertions such as cy.get(“tr”).each(($tableRow) => { cy.wrap($tableRow).find(‘td’).eq(1).should(“contain”, “someText”  }).
  • At times, elements may be out of view of the page, so you’ll need to scroll the element into view first such as cy.get(“.buttonFarBelow”).scrollIntoView().
  • Sometimes you’ll need a longer timeout than the default command timeout, so you can optionally add a { timeout: timeoutInMs } like cy.get(“.someElement”, { timeout: 10000 }).

Interacting with elements

These are the most used interactions found throughout our Cypress tests. Occasionally, you’ll need to throw in a { force: true } property in those function calls to bypass some checks with the elements. This often occurs when an element is covered in some way or derived from an external library that you do not have much control over in terms of how it renders elements.

  • We need to click many things such as buttons in modals, tables, and the like, so we do things like cy.get(“.button”).click().
  • Forms are everywhere in our web applications to fill out user details and other data fields. We type into those inputs with cy.get(“input”).type(“somekeyboardtyping”) and we may need to clear out some default values of inputs by clearing it first like cy.get(“input”).clear().type(“somenewinput”). There are also cool ways to type other keys like {enter} for the Enter key when you do cy.get(“input”).type(“text{enter}”).
  • We can interact with select options like cy.get(“select”).select(“value”) and checkboxes like cy.get(“.checkbox”).check().

Asserting on elements

These are the typical assertions you can use in your Cypress tests to determine if things are present on the page with the right content.

  • To check if things show up or not on the page, you can switch between cy.get(“.selector”).should(“be.visible”) and cy.get(“.selector”).should(“not.be.visible”).
  • To determine if DOM elements exist somewhere in the markup and if you do not necessarily care if the elements are visible, you can use cy.get(“.element”).should(“exist”) or cy.get(“.element”).should(“not.exist”).
  • To see if an element contains or does not contain some text, you can choose between cy.get(“button”).should(“contain”, “someText”) and cy.get(“button”).should(“not.contain”, “someText”).
  • To verify an input or button is disabled or enabled, you can assert like this: cy.get(“button”).should(“be.disabled”).
  • To assert on whether something is checked, you can test like, cy.get(“.checkbox”).should(“be.checked”).
  • You can usually rely on more tangible text and visibility checks, but sometimes you have to rely on class checks like cy.get(“element”).should(“have.class”, “class-name”). There are other similar ways to test attributes as well with .should(“have.attr”, “attribute”).
  • It’s often useful for you to chain assertions together too like, cy.get(“div”).should(“be.visible”).and(“contain”, “text”).

Dealing with APIs and services

When dealing with your own APIs and services related to email, you can use cy.request(...) to make HTTP requests to your backend endpoints with auth headers. Another alternative is you can build out cy.task(...) plugins that can be called from any spec file to cover other functionality that can be best handled in a Node server with other libraries such as connecting to an email inbox and finding a matching email or having more control over the responses and polling of certain API calls before returning back some values for the tests to use.

Making HTTP requests with cy.request(…)

You may use cy.request()  to make HTTP requests to your backend API to set up or tear down data before your test cases run. You usually pass in the endpoint URL, HTTP method such as “GET” or “POST”, headers, and sometimes a request body to send to the backend API. You can then chain this with a .then((response) => { }) to gain access to the network response through properties such as “status” and “body”. An example of making a cy.request() call is demonstrated here.

At times, you may not care about whether or not the cy.request(...) will fail with a 4xx or 5xx status code during the clean up before a test runs. One scenario where you may choose to ignore the failing status code is when your test makes a GET request to check whether an item still exists and was already deleted. The item may already be cleaned up and the GET request will fail with a 404 not found status code. In this case, you would set another option of failOnStatusCode: false so your Cypress tests do not fail before even running the test steps.

Creating reusable plugins with cy.task()

When we want to have more flexibility and control over a reusable function to talk to another service such as an email inbox provider through a Node server (we will cover this example in a later blog post), we like to provide our own extra functionality and custom responses to API calls for us to chain and apply in our Cypress tests. Or, we like to run some other code in a Node server—we often build out a cy.task() plugin for it. We create plugin functions in module files and import them in the plugins/index.ts where we define the task plugins with the arguments we need to run the functions as shown below.

These plugins can be called with a cy.task(“pluginName”, { ...args }) anywhere in your spec files and you can expect the same functionality to happen. Whereas, if you used cy.request(), you have less reusability unless you wrapped those calls themselves in page objects or helper files to be imported everywhere. 

One other caveat is that since the plugin task code is meant to be run in a Node server, you cannot call the usual Cypress commands inside those functions such as Cypress.env(“apiHost”) or cy.getCookie(‘auth_token’). You pass in things such as the auth token string or backend API host to your plugin function’s argument object in addition to things required for the request body if it needs to talk to your backend API.

Mocking network requests with cy.server() and cy.route()

For Cypress tests requiring data that is tough to reproduce (like variations of important UI states on a page or dealing with slower API calls), one Cypress feature to consider is stubbing out the network requests. This works well with XmlHttpRequest (XHR) based requests if you are using vanilla XMLHttpRequest, the axios library, or jQuery AJAX. You would then use cy.server() and cy.route() to listen for routes to mock out responses for any state you want. Here’s an example: 

Another use case is to use cy.server(), cy.route(), and cy.wait() together to listen and wait for network requests to finish before doing next steps. Usually, after loading a page or doing some sort of action on the page, an intuitive visual cue will signal that something is complete or ready for us to assert and act on. For the cases where you don’t have such a visible cue, you can explicitly wait for an API call to finish like this.

One big gotcha is if you’re using fetch for network requests, you will not be able to mock out the network requests or wait for them to finish in the same way. You’ll need a workaround of replacing the normal window.fetch with an XHR polyfill and doing some setup and cleanup steps before your tests run as recorded in these issues. There is also an experimentalFetchPolyfill property as of Cypress 4.9.0 that may work for you, but overall, we are still looking for better methods to handle network stubbing across fetch and XHR usage in our applications without things breaking. As of Cypress 5.1.0, there is a promising new cy.route2() function (see the Cypress docs) for experimental network stubbing of both XHR and fetch requests, so we plan to upgrade our Cypress version and experiment with it to see if it solves our issues.

Custom commands

Similar to libraries such as WebdriverIO, you can create global custom commands that can be reused and chained throughout your spec files, such as a custom command to handle logins through the API before your test cases run. Once you developed them in a file such as support/commands.ts, you can access the functions like cy.customCommand() or cy.login(). Writing up a custom command for logging in looks like this.

About page objects

A page object is a wrapper around selectors and functions to help you interact with a page. You do not need to build page objects to write your tests, but it is good to consider ways for you to encapsulate changes to the UI. You want to make your lives easier in terms of grouping things together to avoid updating selectors and interactions in multiple files rather than in one place.

You can define a base “Page” class with common functionality such as open() for inherited page classes to share and extend from. Derived page classes define their own getter functions for selectors and other helper functions while reusing the base classes’ functionality through calls like super.open() as shown here.

Choosing not to run client-side code with window.Cypress checks

When we tested flows with auto-downloading files such as a CSV, the downloads would often break our Cypress tests by  freezing the test run. As a compromise, we mainly wanted to test if the user could reach the proper success state for a download and not actually download the file in our test run by adding a window.Cypress check.

During Cypress test runs, there will be a window.Cypress property added to the browser. In your client-side code, you can choose to check if there is no Cypress property on the window object, then carry out the download as usual. But, if it’s being run in a Cypress test, do not actually download the file. We also took advantage of checking the window.Cypress property for our A/B experiments running in our web app. We did not want to add more flakiness and non-deterministic behavior from A/B experiments potentially showing different experiences to our test users, so we first checked the property is not present before running the experiment logic as highlighted below.

Dealing with iframes

Dealing with iframes can be difficult with Cypress as there is no built-in iframe support. There is a running [issue](https://github.com/cypress-io/cypress/issues/136) filled with workarounds to handle single iframes and nested iframes, which may or may not work depending on your current version of Cypress or the iframe you intend to interact with. For our use case, we needed a way to deal with Zuora billing iframes in our staging environment to verify Email API and Marketing Campaigns API upgrade flows. Our tests involve filling out sample billing information before completing an upgrade to a new offering in our app.

We created a cy.iframe(iframeSelector) custom command to encapsulate dealing with iframes. Passing in a selector to the iframe will then check the iframe’s body contents until it is no longer empty and then return back the body contents for it to be chained with more Cypress commands as shown below:

collect.chat

When working with TypeScript, you can type out your iframe custom command like this in your index.d.ts file:

To accomplish the billing portion of our tests, we used the iframe custom command to get the Zuora iframe’s body contents and then selected the elements within the iframe and changed their values directly. We previously had issues with using cy.find(...).type(...) and other alternatives not working, but thankfully we found a workaround by changing the values of the inputs and selects directly with the invoke command i.e. cy.get(selector).invoke(‘val’, ‘some value’). You’ll also need ”chromeWebSecurity”: false in your cypress.json configuration file to allow you to bypass any cross-origin errors. A sample snippet of its usage with filler selectors is provided below:

Standardizing across test environments

After writing tests with Cypress using the most common assertions, functions, and approaches highlighted earlier, we are able to run the tests and have them pass against one environment. This is a great first step, but we have multiple environments to deploy new code and to test our changes against. Each environment has its own set of databases, servers, and users, but our Cypress tests should be written just once to work with the same general steps.

In order to run Cypress tests against multiple test environments such as dev, testing, and staging before we eventually deploy our changes to production, we need to take advantage of Cypress’s ability to add environment variables and alter configuration values to support those use cases. 

To run your tests against varying frontend environments:

You will need to change up the “baseUrl” value as accessed through Cypress.config(“baseUrl”) to match those URLs such as https://staging.app.com or https://testing.app.com. This changes up the base URL for all of your cy.visit(...) calls to append their paths to. There are multiple ways to set this such as setting CYPRESS_BASE_URL=<frontend_url> before running your Cypress command or setting --config baseUrl=<frontend_url>.

To run your tests against different backend environments:

You need to know the API host name such as https://staging.api.com or https://testing.api.com to set in an environment variable such as “apiHost” and accessed through calls like Cypress.env(“apiHost”). These will be used for your cy.request(...) calls to make HTTP requests to certain paths like “<apiHost>/some/endpoint” or passed through to your cy.task(...) function calls as another argument property to know which backend to hit. These authenticated calls would also need to know the auth token you most likely are storing in localStorage or a cookie through cy.getCookie(“auth_token”). Make sure this auth token is eventually passed in as part of the “Authorization” header or through some other means as part of your request. There are a multitude of ways to set these environment variables such as directly in the cypress.json file or in --env command-line options where you can reference them in the Cypress documentation

To approach logging in to different users or using varying metadata:

Now that you know how to handle multiple frontend URLs and backend API hosts, how do you handle logging in to different users? How do you use varying metadata based on environment, such as things related to domains, API keys, and other resources that are likely to be unique across test environments? 

Let’s start with creating another environment variable called “testEnv” with possible values of “testing” and “staging” so you can use this as a way to tell which environment’s users and metadata to apply in the test. Using the “testEnv” environment variable, you can approach this in a couple ways. 

You can create separate “staging.json”, “testing.json”, and other environment JSON files under the fixtures folder and import them in for you to use based on the “testEnv” value such as cy.fixture(`${testEnv}.json`).then(...). However, you cannot type out the JSON files well and there is much more room for mistakes in syntax and in writing out all the properties required per test. The JSON files are also farther away from the test code, so you would have to manage at least two files when editing the tests. Similar maintenance issues would occur if all the environment test data were set in environment variables directly in your cypress.json and there would be too many to manage across a plethora of tests.

An alternative option is to create a test fixture object within the spec file with properties based on testing or staging to load up that test’s user and metadata for a certain environment. Since these are objects, you can also define a better generic TypeScript type around test fixture objects for all of your spec files to reuse and to define the metadata types. You would call Cypress.env(“testEnv”) to see which test environment you are running against and use that value to extract out the corresponding environment’s test fixture from the overall test fixture object and use those values in your test. The general idea of the test fixtures object is summarized in the code snippet underneath.

Applying the “baseUrl” Cypress config value, “apiHost” backend environment variable, and “testEnv” environment variable together allows us to have Cypress tests that work against multiple environments without adding multiple conditions or separate logic flows as demonstrated below.

Let’s take a step back to see how you can even make your own Cypress commands to run through npm. Similar concepts can be applied to yarn, Makefile, and other scripts you may be using for your application. You may like to define variations of “open” and “run” commands to align with the Cypress “open” up the GUI and “run” in headless mode against various frontend and backend environments in your package.json. You can also set up multiple JSON files for each environment’s configuration, but for simplicity, you will see the commands with the options and values inline.

You will notice in the package.json scripts that your frontend “baseUrl” ranges from “http://localhost:9001” for when you start up your app locally to the deployed application URL such as “https://staging.app.com”. You can set the backend “apiHost” and “testEnv” variables to help with making requests to a backend endpoint and loading up a specific test fixture object. You may also create special “cicd” commands for when you need to run your tests in a Docker container with the recording key.

A few takeaways

When it comes to selecting elements, interacting with elements, and asserting about elements on the page, you can get pretty far with writing many Cypress tests with a small list of Cypress commands such as cy.get(), cy.contains(), .click(), .type(), .should(‘be.visible’)

There are also ways to make HTTP requests to a backend API using cy.request(), run arbitrary code in a Node server with cy.task(), and stub out network requests using cy.server() and cy.route(). You can even create your own custom command like cy.login() to help you log in to a user through the API. All these things help to reset a user to the proper starting point before tests run. Wrap these selectors and functions altogether in a file and you’ve created reusable page objects to use in your specs.

To help you write tests that pass in more than one environment, take advantage of environment variables and objects holding environment specific metadata.

This will help you run different sets of users with separate data resources in your Cypress specs. Separate Cypress npm commands like npm run cypress:open:staging in your package.json will load up the proper environment variable values and run the tests for the environment you chose to run against.

This wraps up our one thousand foot overview of writing Cypress tests. We hope this provided you with practical examples and patterns to apply and improve upon in your own Cypress tests. Stay tuned for more blog posts related to what we learned from our Cypress tests in typing everything, dealing with email flows, organizing and consolidating, and integrating with Docker and CICD.

Source link