1,000 Foot Overview of Writing Cypress Tests #frontend@twiliosendgrid
Alfred LuceroAt 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:
- Cypress API roundup
- Interacting with elements
- Asserting on elements
- Dealing with APIs and services
- Making HTTP requests with cy.request(…)
- Creating reusable plugins with cy.task()
- Mocking network requests with cy.server() and cy.route()
- Custom commands
- About page objects
- Choosing not to run client-side code with window.Cypress checks
- Dealing with iframes
- 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â]â)
orcy.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 ascy.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 }
likecy.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 likecy.get(âinputâ).clear().type(âsomenewinputâ)
. There are also cool ways to type other keys like{enter}
for the Enter key when you docy.get(âinputâ).type(âtext{enter}â)
. - We can interact with select options like
cy.get(âselectâ).select(âvalueâ)
and checkboxes likecy.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â)
andcy.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â)
orcy.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â)
andcy.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:
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.Â
Interested in learning more about Cypress tests? Check out the following resources: