loading...
Cover image for Cypress Super-patterns: How to elevate the quality of your test suite

Cypress Super-patterns: How to elevate the quality of your test suite

wescopeland profile image Wes Copeland ・11 min read

Cypress is awesome and a game-changer for testing your apps. You get the tools you need out of the box to be instantly productive and ship your code with confidence. Over the last year, I have relied on Cypress heavily for multiple industry projects and several hobby projects, and it has elevated my confidence to ship new code through the stratosphere.

However, I don't follow the golden path when using Cypress. I use a couple of plugins that genuinely make a huge difference for productivity. In this article, I will share my approach to Cypress testing and how you can use it to take your tests to the next level.


🤷‍♂️ Why Cypress?

You're likely already using Jest or Karma as a test runner for your unit tests. Without going too deep, unit tests are great and you should still write them. At best, they verify the correctness of your components and services under deliberately isolated use cases. At worst, they are testing implementation details to turn some green lights on.

To understand why Cypress is a great tool, it is helpful to first understand the Testing Trophy. This is Kent C. Dodds' compelling front-end take on the traditional testing pyramid:

The testing trophy

The trophy boils down a comprehensive front-end testing strategy to four levels:

  • Static Analysis. This includes tools like Prettier, ESLint, and even TypeScript. All of these are raising the floor of your code quality before code is even committed to the codebase.
  • Unit Testing. Jest and Karma are the most popular tools for running unit tests in the JavaScript ecosystem. Unit tests are very quick, can be run in parallel, and verify deliberately isolated situations for correctness. When I say quick, ideally you can run a few thousand of these in under a minute.
  • Integration Testing. Jest and Karma are also great at integration testing. Cypress is my tool of choice. In an integration test, multiple units are interacting with each other and the outcome is being tested. For example, maybe you've spun up your front-end app but are using a mock back-end to verify the correctness of the UI.
  • E2E Testing. This is as close to end-user testing as we can achieve in an automated way. With E2E testing, we have a helper robot that walks through the app, hitting a real UI, a real back-end, and a real database. While these tests give us the highest confidence, they are the most expensive in terms of time and maintenance. Cypress is an excellent tool of choice for E2E tests.

The slices of the testing trophy are intentionally sized: integration tests are in the sweet spot of time and confidence.

Hopefully, at this point, it is clear that Cypress can add value to your toolchain if you're not doing any testing above the unit level.


🤷‍♀️ How do I set up Cypress?

The Cypress team has done a great job of making setup as easy as can be. The docs here should be treated as the ultimate authority, but a nice tl;dr might be:

1. Install the Cypress dependency into your project.

This can be done with a terminal command based on your package manager of choice:

npm install --save-dev cypress

OR

yarn add -D cypress

2. Add some Cypress scripts to your package.json file.

In your package.json's scripts object, add:

"scripts": {
  ...

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

3. Start Cypress!

With Cypress installed and your commands added, you're ready for liftoff! In a terminal, run:

npm run cy:open

OR

yarn cy:open

On its first run, Cypress will bootstrap your project with a lot of sample tests in your project's cypress/integration folder. I do recommend deleting all of these, as we will be greatly diverging from the default way of writing Cypress tests for the remainder of the article.


Super-pattern #1: Use Cypress Testing Library

If you only implement one super-pattern, it should be this one.

The above tweet is the key guiding principle of Testing Library. Testing Library is a reaction to other testing toolkits (including the default API that ships with Cypress) that they might perhaps give a developer too much power or encourage them to test things that are invisible to an end-user.

Testing Library's API is deliberately minimal to keep developers in the pit of success. Most of its exposed functionality only allows you to test software in a way it's used by real end-users. This gives you a massively increased confidence in the tests themselves and the code you ship to production, the only trade-off being you as the developer must ensure your application is actually accessible.

In the grand scheme of things, this is not much of a trade-off at all. If your app is not accessible, your app is broken.

Another benefit of using Cypress Testing Library is there's a non-zero chance you're already using another flavor of Testing Library for your unit tests. Create React App now ships with React Testing Library by default. If this is the case, the context switch of moving from your Jest tests to your Cypress tests is greatly reduced.

How to set up Cypress Testing Library

Thankfully, the setup for CTL can be completed in just a few steps. First, let's install the needed dependency:

npm install --save-dev @testing-library/cypress

OR

yarn add -D @testing-library/cypress

Next, you will need to open cypress/support/commands.js and add the following near the top of the file:

import '@testing-library/cypress/add-commands';

If you are using TypeScript with Cypress (which I do not recommend in any project not using Nrwl Nx due to its noticeable performance impact on test execution speed when paired with Cucumber), there are some additional steps you'll need to follow that can be found in the CTL setup docs.


🚀 Improve your Testing Library productivity!

There are a few tools and tricks you can leverage to greatly improve your capabilities with CTL. In my work, two have stuck out well above the rest.

#1 - Which query should I use?

This is your bible. It is critically important that you prioritize the correct queries to gain the full benefits of Testing Library. Note that accessible queries are the top priority because they can be seen/heard by all users regardless of any a11y considerations, whereas data attributes (which, interestingly, the Cypress docs recommend using) should be treated as the lowest priority because they are invisible and inaccessible to the end-user.

This page is strangely easy to miss in the Testing Library docs, but it's always one of the first I share with anyone just getting up to speed with any flavor of the library.

#2 - 🐸 Testing Playground Chrome Extension

This tool is a relative newcomer to the scene. Written by Stephan Meijer, this adds another tab to your Chrome DevTools that lets you select any element on the page (just like the element selector already built into the devtools) and receive the recommended Testing Library query to use!

There's an added unspoken benefit here though. If the tool cannot recommend a query, it means whatever you're pointing at is probably not accessible. This certainly removes a lot of guesswork from the equation.


Super-pattern #2: Force the Cypress timezone

Most apps, at some point, will receive datetime information from the back-end that must be displayed on the UI. Generally speaking, working with dates and times is hard and error-prone, so it's definitely something we'll want to test.

By default, Cypress uses your machine's timezone just like your web browser does. This can have unintended consequences. If your CI server (or another developer) is set to a different timezone, tests that pass on your machine will fail on others. At the time of writing, there is an open discussion on GitHub about this problem here.

Our tests should be deterministic. To achieve this with timezones, we'll force Cypress's timezone to UTC (this should match most CI providers).

We can achieve this with a very slight modification to our scripts in the package.json file:

"cy:open": "TZ=UTC cypress open",
"cy:run": "TZ=UTC cypress run"

All done! Now no matter where your tests run, they'll simulate being in the UTC timezone.


Super-pattern #3: Use Cucumber

What the heck is Cucumber? You may also hear this referred to as "Gherkin" or "feature files".

Cucumber adds some guardrails to your Cypress tests, giving you a syntax that enables tests to very closely follow the AAA (Arrange, Act, Assert) testing pattern.

Cucumber tests live in .feature files and have a unique syntax (Gherkin) that closely mirrors the language of a user story. A Gherkin file might look like:

# HomePage.feature
Feature: Home Page

  Scenario: The Sign Up link navigates to the correct page
    Given I navigate to the home page
    When I click on the Sign Up link
    Then I should be on the Sign Up page

With Cucumber, this is an actual test file that Cypress can execute. Each "test step" (given/when/then) maps to a re-usable function, like so:

// HomePage.steps.js
import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps';

Given('I navigate to the home page', () => {
  cy.visit('https://my-app.com');
});

When('I click on the Sign Up link', () => {
  cy.findByRole('button', { name: /Sign Up/i }).click();
});

Then('I should be on the Sign Up page', () => {
  cy.findByText('Sign Up now!').should('be.visible');
});

There are numerous benefits to using Cucumber in your Cypress tests:

  • Each test step follows a UNIX-like single-purpose principle, in that they are good at doing only one thing and are authoritative in how that thing should be done.
  • Test steps are typically small and concise, making them easier to maintain.
  • Multiple tests can be built from the same set of test steps, with developers using them just like jigsaw puzzle pieces. This keeps your tests extremely DRY.
  • Test steps are reusable between feature files and integration/e2e tests!
  • Failures can be traced down to a bite-sized step function rather than a massive test method, as the steps themselves are generally stateless.

We can use Cucumber in Cypress via cypress-cucumber-preprocessor. Setup is non-trivial but well worth the effort.

The installation docs on the official GitHub repo are the best resource for how to get started. I would much rather link to these steps directly instead of carbon copy them in this article, as they are surely subject to change.

What I can share though are some productivity tips with Cucumber that are not especially obvious during the setup phase.


🚀 Improve your Cucumber productivity!

#1 - Rename the integration folder to tests.

One of the main draws of using Cucumber is we can re-use test steps across both integration and e2e tests in the same codebase. By default, Cypress names the folder all of your tests live in "integration". This makes a lot of sense for most codebases but will be suboptimal for our use case.

To make this change, you must configure it in two places: cypress.json and the cypress-cucumber-preprocessor settings in your package.json.

// cypress.json

{
  ...

  "testFiles": ["**/*.{feature,features}"],
  "integrationFolder": "cypress/tests"
}
// package.json

{
  ...

  "cypress-cucumber-preprocessor": {
    "stepDefinitions": "cypress/tests"
  }
}

#2 - Inside of tests, add common, integration, and e2e.

That's right, we want to add three new folders inside of our tests folder. We should name them common, integration, and e2e.

Why?

common is where shared steps between integration and e2e tests can live. cypress-cucumber-preprocessor specifically looks for this folder for global steps. For example, if you want common navigation test steps that are re-usable by any feature file, they might go in common/navigation.steps.js.

We separate our integration and e2e tests in separate folders because we likely want separate CI processes for them (integration on every PR, e2e nightly or on every merge). This folder separation will make setting that up later down the road quite a bit easier.

#3 - Enable non-global step definitions.

What about step definitions that are isolated specifically to a certain feature file? We should support this so that not every step definition is global.

This can be enabled in the package.json file:

{
  ...

  "cypress-cucumber-preprocessor": {
    ...

    "nonGlobalStepDefinitions": true
  }
}

This might make a lot of sense for integration tests, where we have a test step for setting up a mock API response. We certainly don't want mock API response steps to be accessible in our e2e tests.

#4 - Put your i18n solution in CI mode for integration tests.

How you do this will largely depend on what front-end framework and i18n framework you're using in your project. i18next supports a "cimode" language out of the box.

What this means is integration tests will render i18n keys, whereas e2e tests hitting your actual app will show the actual i18n values. For integration testing, this can give you some confidence that you're shipping the correct i18n value regardless of the language selected by the end-user.

This can be tested in Cucumber like so:

# Integration Test
Feature: Sign Up Page

  Scenario: The heading text is visible
    Given @navigation I visit the Sign Up page
    Then @sign-up I should see the heading text "headingText"
# E2E Test
Feature: Sign Up Page

  Scenario: The heading text is visible
    Given @i18n My language is set to "ja-JP"
    And @navigation I visit the Sign Up page
    Then @sign-up I should see the heading text "サインアップ"
// Curly brackets let us pass dynamic data to test steps
Then('@sign-up I should see the heading text {string}', (value) => {
  cy.findByText(value).should('be.visible');
});

#5 - Tag your global test steps.

This will save you from a massive headache down the road. Tag every test step that lives in the common folder based on the name of the file it's in. For example:

// home.steps.js

Given('@home I click the Sign Up button', () => {
  cy.findByRole('button', { name: /Sign Up/i }).click();
});
Scenario: Sample
  Given @home I click the Sign Up button

Now any developer who is responsible for fixing the inevitable failure doesn't have to guess whether or not any step is global, and for those that are global, they know exactly what file the step belongs to. This will also prevent naming collisions between global steps.

#6 - Add Gherkin support to VSCode.

This is the plugin you want. VSCode does not natively support Gherkin syntax, so this plugin has the potential to further elevate your productivity.

#7 - Write steps from the perspective of the user.

// 🛑 bad
Given("The button is visible", () => { ... });

// ✅ good
Given("I see the button", () => { ... });

This is purely semantics, but I've found this helps you remain focused on the end-user experience rather than the implementation details of the page.


Thanks for reading! & additional resources

If this article was helpful at all or if you learned anything new, please leave a like! Here are some follow-up resources if you're interested in diving any deeper:

You can also follow me on Twitter, where I'm always posting or retweeting interesting things happening in the community.

Thanks for reading! 👋

Posted on by:

wescopeland profile

Wes Copeland

@wescopeland

👋 Classic arcade nerd, passionate about developer experience and learning new things.

Discussion

pic
Editor guide
 

Cypress is a industry disrupter. It crosses multiple testing definitions and can be used for almost any phase. Its http interception distinguishes it as the premier leader in the field. It's easy to learn and fun at the same time. Serious tooling for the serious test effort.

 

So nice post!
Introducing 'pragmatic' frontend testing history from several years ago.
Jest coming, Kent made React Testing Library, starting service Sypress.io, each history written by clean text that contain at the time background.

I want to recommend someone who web developer! 🤗

 

Awesome article, so many tips spread throughout this on top of the approach to using Cypress!

Some comments have been hidden by the post's author - find out more

Code of Conduct Report abuse