At Simple Thread we often use Cypress for writing end-to-end (E2E) tests. Its asynchronous command chaining and built-in retrying make it incredibly easy to quickly write tests that consistently work without a lot of flake. By ensuring that the tests are deterministic, we can reliably debug failures and increase stability and confidence in both our tests and applications. This makes a comprehensive E2E test suite extremely valuable.
However, E2E tests are themselves code, and can suffer from a lot of the same problems as application code. When adding a regression test, it’s all too easy to simply copy an existing test and tweak it, thereby ✨increasing coverage✨ with only a little effort. When testing a new feature that is similar in structure and function to an existing one, it’s again very easy to simply copy the test file for the existing feature and modify it to fit the new feature, reusing a lot of existing patterns in the process.
The Problem of Duplicated Code
But this can create problems. By duplicating code, we have duplicated all the benefits but also all of the deficits of the source. Any bugs or coupling present in the source now exist in two places. This is a common problem, and a reason why we try to keep our code DRY.
My biggest issue with the proliferation of unabstracted patterns in E2E tests is how long and unwieldy specs can get, often being written and then left to be read and debugged when they fail a CI run. It’s too easy to see long E2E tests as a good thing because, alongside a feature, they increase our confidence in what we have built and affirm that we are being diligent and responsible. It’s only when they fail, or become unperformant and need to be optimized, that we see how problematic they are.
I like to approach this in terms of readability, because the first thing I ask myself when debugging a failing test is “ok, what is this test doing?”. Given that the goal of E2E tests is to simulate user behavior, I contend that what a test is doing–beyond its name, actually at the level of actions and expectations–should be nontechnical and immediately obvious with a minimum of mental overhead.
A Brief Example of Readability
Consider a function that processes a user’s message in a chat system. I argue that this:
function processMessage(message) {
const mentions = message.match(/@w+/g) || [];
const hashtags = message.match(/#w+/g) || [];
const urls = message.match(/https?://[^s]+/g) || [];
const emojis = message.match(/:w+:/g) || [];
return { mentions, hashtags, urls, emojis };
}
is less readable that this:
const extractMentions(message) => message.match(/@w+/g) || [];
const extractHashtags(message) => message.match(/#w+/g) || [];
const extractUrls(message) => message.match(/https?://[^s]+/g) || [];
const extractEmojis(message) => message.match(/:w+:/g) || [];
function processMessage(message) {
const mentions = extractMentions(message);
const hashtags = extractHashtags(message);
const urls = extractUrls(message);
const emojis = extractEmojis(message);
return { mentions, hashtags, urls, emojis };
}
The two snippets are functionally the same. But by extracting the bits that perform the regex matching, one can read the high-level function that processes the message without having to drop into a lower-level mode of thinking. Dropping into that lower level requires changing expectations about the granularity of the code and engaging different parts of the mind, in this case switching from a high-level “process this message” workflow to low-level mental parsing of regex.
Enhancing Readability with Custom Commands
The goal of E2E tests is to simulate a “real user scenario” from start to finish. When reading such a test, it should answer the question “what is this test doing?” using terminology that a user might be familiar with. The implementation details are entirely secondary to what the test is doing, and if exposed, actually antithetical to thinking like a user and minimizing mental fatigue while debugging.
Consider the following test which submits a comment via a public form on a web page.
// comment-form-1.cy.js
describe("Comment form", () => {
it("allows comments to be submitted", () => {
cy.visit("leave-a-comment.html");
cy.get("#name").type("John Smith");
cy.get("#email").type("john.smith@email.invalid");
cy.get("#phone").type("012-345-6789");
cy.get("#comments").type("Lorem ipsum dolor sit amet");
cy.contains("Submit").click();
cy.contains(".toast-header", "Success").should("be.visible");
cy.contains(".toast-body", "Your comment was received").should("be.visible");
cy.get("#name").should("be.disabled");
cy.get("#email").should("be.disabled");
cy.get("#phone").should("be.disabled");
cy.get("#comments").should("be.disabled");
cy.contains("Submit").should("be.disabled");
});
});
Cypress gives us a nice chainable API that makes testing the form submission relatively easy. However, this still looks much like a computer program and not like the actions of a real user. What this test is doing is:
1) visiting a page
2) filling out several form inputs
3) clicking submit
4) seeing a message
5) seeing that the form is now disabled
It is perfectly describable with natural language. I am suggesting that we strive to have our tests read like natural language by burying all the technical stuff under natural-looking helper functions.
The main tool Cypress gives us to accomplish this is the ability to define custom commands. Commands are methods on the cy
object–visit
, get
, contains
, etc.–and methods chained from those–type
, click
, etc. They are basically helper functions, permanently available on cy
, that can be chained together with Cypress’ own native commands. This makes them a great choice for wrapping patterns that show up over and over in tests.
The first thing I want to do to spruce up this test is replace all instances of getting form elements by their IDs with something that is more user-like. In this case we are going to write a command that accepts the text of a label and returns the associated input for it. Because we are encapsulating the logic in a reusable command, we can afford to do something a little more complicated than what we currently have.
Cypress.Commands.add("input", function(labelText) {
cy.contains("label", labelText).then($label => {
cy.get(`#${$label.attr("for")}`);
});
});
Here I am leveraging the fact that in HTML, labels specifically target form inputs by their ID. But instead you might utilize the fact that your form inputs are nested inside labels, or that both the label and input always sit together inside a stylized wrapper.
Notice that the command does not return a value. This is because of how Cypress manages commands. When you call a command, this actually adds the command to an internal command queue. Cypress will run them in order and manage the passing of the subject value to chained commands.
Now we can replace all DOM ID references with this new input
command.
// comment-form-2.cy.js
describe("Comment form", () => {
it("allows comments to be submitted", () => {
cy.visit("leave-a-comment.html");
cy.input("Name").type("John Smith");
cy.input("Email").type("john.smith@email.invalid");
cy.input("Phone").type("012-345-6789");
cy.input("Comments").type("Lorem ipsum dolor sit amet");
cy.contains("Submit").click();
cy.contains(".toast-header", "Success").should("be.visible");
cy.contains(".toast-body", "Your comment was received").should("be.visible");
cy.input("Name").should("be.disabled");
cy.input("Email").should("be.disabled");
cy.input("Phone").should("be.disabled");
cy.input("Comments").should("be.disabled");
cy.contains("Submit").should("be.disabled");
});
});
Next, I want to rework the toast section by wrapping both expectations in a single command. Thinking like a user, we only care about the toast’s flavor, title, and body. The implementation details (in this case Bootstrap classes) are completely irrelevant to our test. Again, by creating a command, we can add additional hidden complexity, in this case chaining the assertions so that we are certain that the title and body are both part of the same toast with the given flavor. This can be important if your application allows toasts to stack.
Cypress.Commands.add("toast", function(flavor, title, body) {
cy.get(`.toast.text-bg-${flavor}`)
.should("be.visible")
.contains(".toast-body", body)
.parent(".toast")
.contains(".toast-header", title);
});
// comment-form-3.cy.js
describe("Comment form", () => {
it("allows comments to be submitted", () => {
cy.visit("leave-a-comment.html");
cy.input("Name").type("John Smith");
cy.input("Email").type("john.smith@email.invalid");
cy.input("Phone").type("012-345-6789");
cy.input("Comments").type("Lorem ipsum dolor sit amet");
cy.contains("Submit").click();
cy.toast("success", "Success", "Your comment was received");
cy.input("Name").should("be.disabled");
cy.input("Email").should("be.disabled");
cy.input("Phone").should("be.disabled");
cy.input("Comments").should("be.disabled");
cy.contains("Submit").should("be.disabled");
});
});
Given that we’ve wrapped up the toast nicely, why not close it as well? Cypress commands are chainable, and we have the power to create commands that operate on the results of other commands. These are called “child” commands as opposed to default “parent” commands. It is worth noting that commands can also be “dual” and function as either a parent or child command.
Here, we specify that this command requires a previous subject, meaning it is a child command. Note that we are “wrapping” the subject
argument, a jQuery object, to create a Cypress object that we can call commands on.
Cypress.Commands.add("close", { prevSubject: true }, function(subject) {
cy.wrap(subject)
.should("be.visible")
.get(‘[aria-label="Close"]’)
.click();
cy.wrap(subject)
.should("not.be.visible");
});
We also need to modify the toast command so that the last thing it does is find the parent .toast
element, which will then be the subject passed into close
.
Cypress.Commands.add("toast", function(flavor, title, body) {
cy.get(`.toast.text-bg-${flavor}`)
.should("be.visible")
.contains(".toast-body", body)
.parent(".toast")
.contains(".toast-header", title)
.parent(".toast"); // added this line
});
With this custom close
command, all we have to do is chain it on our previous toast
command:
// comment-form-4.cy.js
describe("Comment form", () => {
it("allows comments to be submitted", () => {
cy.visit("leave-a-comment.html");
cy.input("Name").type("John Smith");
cy.input("Email").type("john.smith@email.invalid");
cy.input("Phone").type("012-345-6789");
cy.input("Comments").type("Lorem ipsum dolor sit amet");
cy.contains("Submit").click();
cy.toast("success", "Success", "Your comment was received")
.close();
cy.input("Name").should("be.disabled");
cy.input("Email").should("be.disabled");
cy.input("Phone").should("be.disabled");
cy.input("Comments").should("be.disabled");
cy.contains("Submit").should("be.disabled");
});
});
I think this is much more readable than when we started, but I have one more change to make. I’m going to create a button
command.
Cypress.Commands.add("button", function(value) {
cy.contains(".btn", value);
});
This is a simple command and you could argue that it isn’t necessary. My opinion is that a button is a common, distinct UI component, one that creators and users often think in terms of. Therefore I think it makes sense to capture that semantically in our tests. I also think it reads better than contains
. When scanning a test visually, it stands out a bit. In the command I am identifying a button by the presence of the Bootstrap .btn
class, but you could also target the ARIA button
role. Either way, in the test we can now specify that we are looking for a “button”, leaving the implementation details to the command, not worrying whether the button is actually a button
, input[type=button]
, or a
(anchor) element.
// comment-form-5.cy.js
describe("Comment form", () => {
it("allows comments to be submitted", () => {
cy.visit("leave-a-comment.html");
cy.input("Name").type("John Smith");
cy.input("Email").type("john.smith@email.invalid");
cy.input("Phone").type("012-345-6789");
cy.input("Comments").type("Lorem ipsum dolor sit amet");
cy.button("Submit").click();
cy.toast("success", "Success", "Your comment was received")
.close();
cy.input("Name").should("be.disabled");
cy.input("Email").should("be.disabled");
cy.input("Phone").should("be.disabled");
cy.input("Comments").should("be.disabled");
cy.button("Submit").should("be.disabled");
});
});
Conclusion
I think this test has come a long way. It’s now more readable and fittingly high-level, but the improvements don’t stop there. Interestingly, we’ve created helpers that add a layer of hidden complexity that makes our tests even better. By encapsulating element lookup and reducing coupling to the DOM, we’ve made the tests less brittle, as they no longer depend heavily on hardcoded selectors. Furthermore, the creation of complex command chains has enabled us to replace a series of statements against a single component, resulting in more targeted and comprehensive expectations.
Thanks for reading! You can see the working code over on GitHub if you want to take a deeper look.
Loved the article? Hated it? Didn’t even read it?
We’d love to hear from you.