Overview

Angular officially announced the retirement of Protractor, its end-to-end testing tool, on April 24th. Whether Angular will provide a direct replacement or leave the choice to the community remains uncertain. At the moment, several frameworks — including WebDriver.IO, TestCafé, and Cypress — offer schematics for the Angular CLI.

This piece continues where my previous discussion of E2E frameworks left off, guiding you through the practical steps of setting up and running tests with Cypress.

All source code is available on GitHub:

GitHub logo rainerhahnekamp / angular-cypress

Cypress implementation for Angular v12

If you'd rather watch than read, my recorded talk on this topic is available here:

Core Concepts

Getting started with Cypress is remarkably straightforward. Beginning with Angular 12, a single command — npx ng add @cypress/schematic — sets everything up for you. If you're using nx (my personal recommendation), Cypress comes pre-configured out of the box.

The test structure in Cypress follows familiar JavaScript testing patterns. The describe block establishes a test suite, within which individual test cases are defined using it. Test files live in the /cypress/integration directory.

E2E tests simulate the actions a human tester would perform: inspecting the page, clicking elements, and entering text. Each of these interactions corresponds to a dedicated Cypress command, exposed as methods on the global cy object. These methods chain together to construct complex user journeys.

Interacting with the DOM begins with locating the element. The cy.get("some-selector") method handles this lookup, after which you can invoke actions like click() or type("some text"). A typical button click looks like cy.get('button').click(). Simplicity is the name of the game here.

The real purpose of a test, however, is verification. After clicking that button, you expect to see some feedback — perhaps a confirmation message. Say you anticipate the text "Changes have been saved" appearing in a paragraph with the selector p.message. The assertion would be: cy.get('p.message').should('contain.text', 'Changes have been saved');

Writing Your First Test

Let's bring the scenario above to life with an actual test.

First Test
First Test

Creating this test is quick. First, add a new file named home.spec.ts in the /cypress/integration folder. Here's the code you'd write:

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

Running it is equally easy. Ensure your Angular app is up and running, then execute npx cypress open or npm run cypress:open. In the Cypress interface, click on home.spec.ts; a new browser window will launch and immediately execute the test.

Cypress UI
Cypress UI

Cypress Test Runner
Cypress Test Runner

Did it pass? Excellent. But how about running tests in a CI pipeline? Switching from the interactive mode is simple: use npm run cypress:run instead. This executes the tests headlessly.

With no visual feedback, Cypress steps in by automatically recording video output. These recordings are stored in /cypress/videos. Additionally, any test failures trigger screenshots, saved to /cypress/screenshots.

Avoiding Flaky Tests

Consider a test that involves adding a new customer. The workflow is: click the "Customers" button in the sidebar to load the customer list, which then reveals an "Add Customer" button. You click that too:

Flaky Test
Flaky Test

A test for this process might look like this:

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

Running this test often results in a puzzling failure:

Flaky Test

The error suggests Cypress can't find the "Add Customer" link, even though it's clearly visible. What's happening here?

The explanation is straightforward but subtle. You might assume that cy.get("a")contains("Add Customer") continues searching for a link with that text for up to 4 seconds. That assumption is incorrect.

The code actually represents two sequential commands. First, Cypress queries for all link elements. Once it finds one or more, it applies the subsequent command to those results. In this case, the "Add Customer" link doesn't appear immediately after clicking "Customers". When Cypress performs the initial link search, it only discovers two: the "Customers" link and the header logo. It then spends its allotted time waiting for one of those two links to acquire the text "Add Customer".

Sometimes, the "Add Customer" link renders quickly enough that Cypress finds all three links and the test passes. Other times, it doesn't, and the test fails. The result is an intermittently failing test — a developer's nightmare.

Keep these two fundamental rules in mind:

  1. Successful commands are not retried
  2. Chained commands are executed as separate steps

How do we prevent this? The solution involves crafting better selectors that sidestep the multi-step selection process. My preferred approach uses data-test attributes with unique identifiers on DOM elements. The markup for the two links would be revised as follows:

<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

The corresponding test would then be rewritten as:

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

Watch Out for Async Behavior

Cypress commands such as cy.get come with built-in retry logic. They will repeatedly attempt to execute an action or locate an element until it succeeds. This retry mechanism operates asynchronously. One might interpret 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

Even though these commands expose a then method, they should not be confused with Promises. Indeed, avoid writing code in the manner shown above. Cypress internally queues and schedules the commands. You must remain mindful of its "internal asynchronicity" and refrain from combining it with synchronous code, as in this example:

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

When this test runs, the outcome is:

image

So what went wrong? It appears the application never even launched! That's correct. Cypress recorded all the cy commands for asynchronous execution, but the let declaration and the throw condition are synchronous. Consequently, the test failed before Cypress could process the asynchronous parts. Keep this in mind. Synchronous code is only permissible inside then callbacks.

This wraps up our brief introduction to Cypress. For further steps, I suggest making the switch to Cypress.io. Their official documentation is excellent.

Finally, allow me a bit of self-promotion 😅. AngularArchitects.io offers a 3-day testing workshop tailored for Angular developers. It covers Cypress and is offered publicly, though it can also be arranged for private company sessions.

Additional Resources