Skip to content

Functional testing with Cypress

Functional testing with Cypress

When I was a junior developer, 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 poorly. 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. 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 the 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 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, and 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 a lot of features, like video recording, time travel, and 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 works with all types of network requests, including the Fetch API, page loads, XMLHttpRequests, resource loads, etc. The good thing about interceptors is 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 a 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.');
      });
  });
});
When there is an empty pizza list response test run

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');
    });
  });
});
When the pizza list response returns actual pizzas

But this test fails. The image while existing 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');
    });
// ...
When there is a proper pizza list response, and image responses

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!');
    });
  });
// ...
When errors occur

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.