Would you like to TALK about it?

Functional testing with cypress

March 31, 2021 | Originally published on the ThisDot blog

cover

I was a junior developer back in the days when I had an important feature assigned to me. I did the implementation, wrote several unit tests and it worked on my machine. It went through the reviews and got merged. And the next day, when I presented my changes to the stakeholders, everything went bad. It turned out, that somebody forgot to scope their css and my layout was destroyed.

That disaster couldn’t have been avoided by only writing unit tests. The team’s only manual tester did not have time to go through everything in the staging environment. Automated e2e tests might have caught the issues, but those tests were already slow, and adding css verification would have slowed them further. And we only tested the happy-paths against the full system anyways.

What is functional testing?

Functional testing is a quality assurance process and a type of black-box testing that bases its test cases on the specifications of the software component under test. Functions are tested by feeding them input and examining the output, and internal program structure is rarely considered.

from Wikipedia

Functional tests verify how parts of a system function in isolation. It tests the components and how they interact with each other in an environment, with high-level dependencies mocked. You can test an API functionally by sending requests to its endpoints, but testing a complete UI requires a lot of stubbing.

I wrote my first functional tests not long after that incident, but those were not better than e2e tests. For it to work, I needed to write a mock server. The goal was to run these tests in our CI pipeline, but it was not easy, and it required a large infrastructure, running a dev-server, a mock server and a Selenium server, and the test runner requires some “metal”.

How can Cypress help you?

The tests ran and helped us to develop a more stable application. But after a while, the more tests we had, the more failures we had in CI. Sadly, while the tests ran on our well-equipped developer machines, in CI there were memory issues and the whole environment was flaky. After a while, we started to spend more and more time investigating and trying to fix flaky test-runs than on features.

Later, I was working on another project when I heard about Cypress. Cypress is a modern testing tool. It is an all-in-one testing framework, assertion library, with mocking and stubbing and without Selenium. Suddenly, I was able to set up working functional tests in a CI/CD pipeline in 3 hours, and it has been running ever since.

Cypress’ has lots of features, like video recording, time travel or the devtools. It is also framework agnostic, so we don’t need to rewrite our tests if we need to rewrite the application in another framework. How does it make it easier to write functional tests? We no longer need to create mock servers, Cypress handles it for us with network request stubbing.

If you would like to play around with the demo application and the cypress tests, feel free to check out the git repository.

How to intercept network requests?

Cypress’ intercept command work with all types of network requests, including Fetch API, page loads, XMLHttpRequests, resource loads, etc. The good thing about interceptors that they can let the request through for e2e testing, but you can also stub their responses. When you write functional tests, every request made to the API should be stubbed.

You can stub responses by using fixtures. Fixtures are static files inside the fixtures folder. Since the intercept command can work with all types of network requests, you can stub images, JSON responses or even mp3 files.

In our example, we are going to write some functional tests on a pizza ordering page. It is a very simple webpage, written in Angular. We have a pizza-list component that fetches the list and handles error scenarios. The template and the component look like the following:

<!--- subscribes to the pizzaList$ observable with the async pipe. If the emitted value is null, it displays the networkError template   --->
<ng-container *ngIf='pizzaList$ | async as pizzas; else networkError'>

<!--- If the returned list is an empty array, that means there is no delivery and displays a message --->
  <h2 data-test-id='no delivery' *ngIf='!pizzas.length'>Sorry, but we are not delivering pizzas at the moment.</h2>

<!--- Display our pizzas -->
  <cat-pizza-display *ngFor='let pizza of pizzas; trackBy: trackBy'
                     [attr.data-test-id]='pizza.name'
                     [pizza]='pizza'>
  </cat-pizza-display>
</ng-container>

<!--- When a network error occurs, display this instead of the above ng-container --->
<ng-template #networkError>
  <h2 data-test-id='server error' class='server-error'>Sorry, an unexpected error occurred!</h2>
</ng-template>
@Component({
  selector: 'cat-pizza-list',
  templateUrl: './pizza-list.component.html',
  styleUrls: ['./pizza-list.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class PizzaListComponent {

  // we fetch the pizzas
  readonly pizzaList$ = this.http.get<Pizza[]>('/api/pizza/list')
    .pipe(
      // If an error occurrs, we return an observable which emits 'null'
      catchError(e => {
        console.error(e);
        return of(null);
      })
    );

  constructor(private http: HttpClient) { }

  // ...
}

We have several scenarios to test:

  1. When there is no delivery, the Sorry, but we are not delivering pizzas at the moment. message is displayed
  2. When there is delivery, the pizzas are displayed
  3. When any kind of network error occurs, Sorry, an unexpected error occurred!

No delivery

Let’s start with the no-delivery message test. We need to intercept the /api/pizza/list call and return an empty array as its body. Then we have to retrieve the message container by its data-test-id property and assert that it exists in the DOM, is visible and contains the above-mentioned message.

describe(`Pizzas page`, () => {
  describe(`When there is an empty pizza list response`, () => {
    beforeEach(() => {
      // We intercept the request, and we return an empty array as the body
      cy.intercept('GET', '/api/pizza/list', { body: [] }).as('emptyList');
      cy.visit('/pizza');
      // As a good practice, we wait for the response to arrive to avoid flaky tests
      cy.wait('@emptyList');
    });

    it(`a message should be displayed`, () => {
      // We select the 'no delivery' text by its defined data-test-id property
      cy.get(`[data-test-id="no delivery"]`)
        .should('exist')
        .and('be.visible')
        .and('contain', 'Sorry, but we are not delivering pizzas at the moment.');
      });
  });
});

Test run 1

With pizzas present

Now, what happens when we do want to interact with pizzas? First, we have to add a pizzas.json file to our /fixtures folder and use that as a static response. When the test is properly set up, we can interact with the displayed pizzas. Please note, that cy.intercept() calls cannot be overridden, therefore, separating our test cases into separate describe blocks is essential.

Let’s start with our pizzas.json file:

[
  {
    "id": 1,
    "name": "Margherita",
    "price": 1290,
    "imageUrl": "/api/pizza/images/1.jpg",
    "description": "Tomato sauce, mozzarella, basil"
  }
]

Then we continue with our test:

describe(`Pizzas page`, () => {
  describe(`when there is a proper pizza list response`, () => {
    beforeEach(() => {
      // We intercept the request, and we return with the above defined .json file
      cy.intercept('GET', '/api/pizza/list', { fixture: 'pizzas.json' }).as('pizzas');
      cy.visit('/pizza');
      // We wait for the response
      cy.wait('@pizzas');
    });

    it(`should display the Margherita pizza`, () => {
      cy.get(`[data-test-id="Margherita"]`) // we retrieve our mocked pizza
        .should('be.visible')               // we assert it is visible
        .find(`img`)                        // we look for the image
        .should('exist')                    // the image should be in the DOM
        .and('be.visible')                  // and it should be visible
        // and have the attribute we've specified in our json file.
        .and('have.attr', 'src', '/api/pizza/images/1.jpg');
    });
  });
});

Test run 2

But this test fails. The image while exists in the DOM, it is not visible. If we check our imageUrl property more closely, it is an API call as well, and we didn’t stub it. So let’s add an image to our fixtures folder, and fix our beforeEach hook.

// ...
    beforeEach(() => {
      // We intercept the request, and we return with the above defined .json file
      cy.intercept('GET', '/api/pizza/list', { fixture: 'pizzas.json' }).as('pizzas');
      // We also intercept all requests for pizza images and return our pizza.jpg
      cy.intercept('GET', '/api/pizza/images/*.jpg', { fixture: 'pizza.jpg' }).as('pizzaImage');
      cy.visit('/pizza');
      // We wait for the response
      cy.wait('@pizzas');
    });
// ...

Test run 3

Network and server errors

We want to test edge cases as well. What happens when the server is unreachable, or the request returns an unexpected error? According to our component template, the Sorry, an unexpected error occurred! message should be displayed on the page. Let’s write our tests.

// ...
  describe(`when an error occurs`, () => {
    it(`as an unknown server error`, () => {
      // First we simulate a server error with our interceptor
      cy.intercept('GET', '/api/pizza/list', { statusCode: 500 }).as('serverError');
      cy.visit('/');
      cy.wait('@serverError');

      // We get the error message, and make sure it exists, visible and contains the proper text
      cy.get(`[data-test-id="server error"]`)
        .should('exist')
        .and('be.visible')
        .and('contain', 'Sorry, an unexpected error occurred!');
    });

    it(`as a network error`, () => {
      // Here we simulate a network error
      cy.intercept('GET', '/api/pizza/list', { forceNetworkError: true }).as('networkError');
      cy.visit('/');
      cy.wait('@networkError');

      // We get the error message, and make sure it exists, visible and contains the proper text
      cy.get(`[data-test-id="server error"]`)
        .should('exist')
        .and('be.visible')
        .and('contain', 'Sorry, an unexpected error occurred!');
    });
  });
// ...

Test run 4

Conclusion

Using Cypress’ intercept feature helps us with testing our application in a controlled environment. This way, we can test its functionality and how its components communicate with each other. Cypress is also framework agnostic, you can replace the underlying implementation of your state management, and these tests will make sure that your app still works the same. You can even replace your whole application and rewrite it in another framework if you have to.


This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.

This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.


Balázs Tápai

Written by Balázs Tápai.
I will make you believe that I'm secretly three senior engineers in a trench-coat. I overcome complex issues with ease and complete tasks at an impressive speed. I consistently guide teams to achieve their milestones and goals. I have a comprehensive skill set spanning from requirements gathering to front-end, back-end, pipelines, and deployments. I do my best to continuously grow and increase my capabilities.

You can follow me on Twitter or Github.