DEV Community

Cover image for Protractor is dead, long live Cypress! - Part 2
Rainer Hahnekamp
Rainer Hahnekamp

Posted on • Originally published at rainerhahnekamp.com

Protractor is dead, long live Cypress! - Part 2

Table of Contents

On 24th April, Angular announced the deprecation of their end-to-end (E2E) testing tool, Protractor. It remains unclear whether there will be a successor or if Angular will delegate this to its users. At the time of this writing, WebDriver.IO, TestCafé and Cypress have come up with schematics for the Angular CLI.

This is the follow-up to my article about E2E frameworks in general in which I will help you get started with E2E testing in Cypress.

You can find the sources files on

GitHub logo rainerhahnekamp / angular-cypress

Showcasing Cypress in Angular v12

If you prefer watching over reading, then this recording of my talk is for you:

Basics

Cypress is extremely easy to use. Starting from Angular 12, you just need to run the schematics like npx ng add @cypress/schematic and voilá, done. If you are on nx, which I recommend, Cypress is already pre-installed.

Cypress tests are written like most of the other tests in JavaScript. describe defines a new test suite and contains multiple test cases, where each one is defined by it. They are located in the folder /cypress/integration.

E2E tests do the same things a human tester would do. They are looking, clicking and typing. Each of these three actions has its own command in Cypress which is actually a method of the global cy object. These methods can be chained to create complex test paths.

Before we can do something with a DOM node, we have to look it up first. This is done via cy.get("some-selector"). Then we can run an action on it such as click() or type("some text"). A click on a button is cy.get('button').click(). Isn't that easy?

Since we write a test we want to verify that something has happened after the click. We expect a text message to appear in a paragraph within the selector p.message. It should show "Changes have been saved". We would assert it like that: cy.get('p.message').should('contain.text', 'Changes have been saved');.

The First Test

Let's just write the test we described above.

First Test
First Test

Given the knowledge we have so far, we can do that in no time. We create the test file in /cypress/integration/home.spec.ts and write following code:

describe("Home", () => {
  it("should click the button", () => {
    cy.visit("");
    cy.get("button").click();
    cy.get("div.message").should("contain.text", "You clicked me");
  })
})
Enter fullscreen mode Exit fullscreen mode

So how do we run it? Again, very easy. Make sure that the Angular application itself is also running and just execute npx cypress open or npm run cypress:open to open Cypress. When you click on home.spec.ts, the test runner opens in another window and immediately runs the test.

Cypress UI
Cypress UI

Cypress Test Runner
Cypress Test Runner

Did it work? Wonderful! Now what do we have to do when a test should run in a pipeline of our CI? Instead of npm run cypress:open, we just execute npm run cypress:run. This runs the test in the headless mode.

Since we can't really see anything, Cypress automatically records the tests and stores the video files in /cypress/videos. Additionally, the failed tests will also be screenshotted under /cypress/screenshots.

Look out for Flakiness

Let's say we want to add a customer in our test. In the sidebar we click on the button "Customers", after that the customer list appears along the button "Add Customer". We click on that as well:

Flaky Test
Flaky Test

A test for that can look like:

it("should add a customer", () => {
  cy.visit(""); 
  cy.get("a").contains("Customers").click(); 
  cy.get("a").contains("Add Customer").click(); 
})
Enter fullscreen mode Exit fullscreen mode

If you run that test, it will probably fail in a very strange way:

Flaky Test

It looks like Cypress cannot find the link with the "Add Customer" even though the button is right in front of it. What's going on there?

The answer is quite clear. We might think that cy.get("a")contains("Add Customer") is continuing to look for a link with the text "Add Customer" for a maximum of 4 seconds. That is not true.

What we see here are two commands which run sequentially. The first command is the lookup for all link tags. If Cypress finds some, it applies the next command on those. In our case, the "Add Customer" link does not immediately render after the click on "Customers". When Cypress looks for links, it only finds two: the "Customers" and the logo in the header. It then waits for the text in one of these two links to become "Add Customer".

In some cases, the rendering of "Add Customer" is fast enough and Cypress will find 3 links and succeed. In other cases, it won’t. So we end up having tests that sometimes fail and sometimes succeed. A nightmare!

Always remember these two rules:

  1. Commands are not retried when they are successful
  2. Chains are multiple commands

So how to avoid it? We should come up with better selectors that avoid splitting the selection process into two commands. I prefer to apply data-test with a unique identifier to my DOM elements. The markup for the two links would look like this:

<a data-test="btn-customers" mat-raised-button routerLink="/customer">Customers</a>
<a [routerLink]="['.', 'new']" color="primary" data-test="btn-customers-add"
mat-raised-button
>Add Customer</a>
Enter fullscreen mode Exit fullscreen mode

We end up with following rewritten test:

it("should click on add customers", () => {
  cy.visit("");
  cy.get("[data-test=btn-customers]").click();
  cy.get("[data-test=btn-customers-add]").click();
})
Enter fullscreen mode Exit fullscreen mode

Be careful with Asynchronity

Cypress commands like cy.get have an awaiting feature built in. This means they will keep trying multiple times until an action is doable or the element is found. That constant retrying happens asynchronously. You could read the test case like this:

it('should click on add customers', () => {
  cy.visit('')
    .then(() => cy.get('[data-test=btn-customers]'))
    .then((button) => button.click())
    .then(() => cy.get('[data-test=btn-customers-add]'))
    .then((button) => button.click());
});

it('should click on add customers', async () => {
  await cy.visit('');
  const button = await cy.get('[data-test=btn-customers]');
  await button.click();
  const button2 = await cy.get('[data-test=btn-customers-add]');
  await button2.click();
});
Enter fullscreen mode Exit fullscreen mode

Although these commands provide a then method, don't mistake them for Promises. And yes, you must not write code like shown above. Cypress queues and runs the commands internally. You have to be aware of its "internal asynchronicity" and avoid mixing it with synchronous code like that:

it('should fail', () => {
  let isSuccessful = false;
  cy.visit('');
  cy.get('button').click();
  cy.get('div.message').then(() => {
    isSuccessful = true;
  });

  if (!isSuccessful) {
    throw new Error('something is not working');
  }
});
Enter fullscreen mode Exit fullscreen mode

After running the test, we get the following result:

image

What happened there? It looks like the application did not even open! That's right. Cypress just queued all cy commands to run them asynchronously but the let and the condition with the throw command are synchronous commands. So the test did fail before Cypress had a chance to run the asynchronous parts. Be aware of that. You can run synchronous code only in the then methods.

And this closes our quick introduction to Cypress. As next steps I recommend that you switch to Cypress.io. The official documentation is superb.

And last but not least, allow me some shameless advertising from my side 😅. AngularArchitects.io provides a 3-day training about testing for Angular developers. It also includes Cypress and is held as public training but can also be booked in-house.

Further Reading

Discussion (0)