Protractor is dead, long live Cypress! – Part 2

On 24th April, Angular announced the deprecation of their E2E testing tool protractor. It was unclear if there will be a successor or if Angular is delegating this to the users themselves. At the time of this writing, WebDriver.IO, TestCafé and Cypress have provided schematics for the Angular CLI.

This is the second part which is about the first steps in Cypress. The first part is about E2E frameworks in general.

You find the sources files on https://github.com/rainerhahnekamp/angular-cypress

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, what 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. Actually, they are methods of the global cy object.

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 already run an action on it. This can be a click() or type("some text"). These methods can be chained. 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 appears 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.

Button with message on click

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");
  })
})

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

Cypress
Cypress' Test Runner

Worked? 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:

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(); 
})

If you run that test, it is very likely that it fails in a very strange way:

It looks like it cannot find the link with the "Add Customer" although it is just 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 will find only two: the "Customers" and the logo in the header. It then waits for one of these two links that their text turns to "Add Customer".

In some cases the rendering of "Add Customer" is fast enough. Then Cypress would find 3 links and succeed. 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, therefore avoid to split up the selection process into two commands. I prefer to apply data-test with 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>

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();
})

Be careful with Asynchronity

Cypress commands like cy.get have an awaiting feature built-in. This means they are retrying 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();
});

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 that "internal asynchronity" 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');
  }
});

After running the test, we get the following result:

Synchronous & Asynchronous Code

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 already 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 https://www.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

Leave a Reply