Front-end testing on steroids

Stubbing an AJAX request is easy and advantageous with Cypress, and allows us to concentrate on testing the front-end application, forgetting about the back-end one. That's really important because E2E tests are often considered the only solution to the front-end testing problem, but with so much disadvantages that they discouraged a lot of developers.

Anyway, two of the three main E2E testing problems were front-end developer independence and edge case replication. We can face both of them are easy with UI Integration Testing and back-end stubs. As an example, we could test the error paths that could happen with the registration flow. At the moment, the state of the art of our integration signup flow is the following:

File: cypress/integration/examplessignup-integration/signup-2.integration.spec.js

/// <reference types="Cypress" />

import { paths } from "../../../../realworld/frontend/src/components/App";
import { noArticles } from "../../../../realworld/frontend/src/components/ArticleList";
import { strings } from "../../../../realworld/frontend/src/components/Register";

context("Signup flow", () => {
  it("The happy path should work", () => {
    const user = {
      username: "Tester",
      email: "user@realworld.io",
      password: "mysupersecretpassword"
    };

    // set up AJAX call interception
    cy.server();
    cy.route("POST", "**/api/users", "fixture:users/signup").as("signup-request");
    cy.route("GET", "**/api/tags", "fixture:tags/empty-tags").as("tags");
    cy.route("GET", "**/api/articles/feed**", "fixture:articles/empty-articles").as("feed");

    cy.visit(paths.register);

    // form filling
    cy.findByPlaceholderText(strings.username)
      .type(user.username)
      .findByPlaceholderText(strings.email)
      .type(user.email)
      .findByPlaceholderText(strings.password)
      .type(user.password);

    // form submit...
    cy.get("form")
      .within(() => cy.findByText(strings.signUp))
      .click();

    // ... and AJAX call waiting
    cy.wait("@signup-request")
      .should(xhr =>
        expect(xhr.request.body).deep.equal({
          user: {
            username: user.username,
            email: user.email,
            password: user.password
          }
        })
      )
      .wait(["@tags", "@feed"]);

    // end of the flow
    cy.findByText(noArticles).should("be.visible");
  });
});

but there are a lot of error paths that are not been covered yet. The RealWorld front-end does not manage "all" possible paths, but then if the back-end app responds with an error "email/user already registered", it prints the error as they are.

email already used email and username already used

Spying the back-end response, it turns out that its something like { errors: { email: "is already taken." } } and the response status is 422. We need to reproduce it with the cy.route, its options contemplate the status option too. All we need to do is passing all exploded options to the cy.route command

cy.route({
  url: "**/api/users",
  method: "POST",
  status: 422,
  response: { errors: { email: "is already taken." } }
}).as("signup-request");

the url, method, and response are not new, the status option is. With a response like this, the error reported by the front-end is "email is already taken.". The test is not so many different from the old one (the happy path flow), that's the full code

it("Should show an error if the back-end report that the email has already been used", () => {
  const user = {
    username: "Tester",
    email: "user@realworld.io",
    password: "mysupersecretpassword"
  };

  cy.server();
  cy.route({
    url: "**/api/users",
    method: "POST",
    status: 422,
    response: { errors: { email: "is already taken." } }
  }).as("signup-request");

  cy.visit(paths.register);

  cy.window()
    .its("appActions")
    .invoke("signup", user);

  cy.wait("@signup-request");

  cy.findByText("email is already taken.").should("be.visible");
});

We could make it even more generic testing the case of multiple errors coming from the back-end

-it("Should show an error if the back-end report that the email has already been used", () => {
+it("Should show some errors if the back-end reports that some data has already been used", () => {
+ const response = { errors: { username: "is already taken.", email: "is already taken." } };
  const user = {
    username: "Tester",
    email: "user@realworld.io",
    password: "mysupersecretpassword"
  };

  cy.server();
  cy.route({
    url: "**/api/users",
    method: "POST",
    status: 422,
-   response: { errors: { email: "is already taken." } }
+   response
  }).as("signup-request");

  cy.visit(paths.register);

  cy.window()
    .its("appActions")
    .invoke("signup", user);

  cy.wait("@signup-request");

- cy.findByText("email is already taken.").should("be.visible");
+ Object.entries(response.errors).map(([subject, error]) => {
+   cy.findByText(`${subject} ${error}`).should("be.visible");
+ });
});

Below there are both the single-error test and the multiple-errors one

File: cypress/integration/examplessignup-integration/signup-error-paths-1.integration.spec.js

/// <reference types="Cypress" />

import { paths } from "../../../../realworld/frontend/src/components/App";

context("Signup flow", () => {
  it("Should show an error if the back-end report that the email has already been used", () => {
    const response = { errors: { email: "is already taken." } };
    const user = {
      username: "Tester",
      email: "user@realworld.io",
      password: "mysupersecretpassword"
    };

    cy.server();
    cy.route({
      url: "**/api/users",
      method: "POST",
      status: 422,
      response
    }).as("signup-request");

    cy.visit(paths.register);

    cy.window()
      .its("appActions")
      .invoke("signup", user);

    cy.wait("@signup-request");

    cy.findByText(`email ${response.errors.email}`).should("be.visible");
  });

  it("Should show some errors if the back-end reports that some data has already been used", () => {
    const response = { errors: { username: "is already taken.", email: "is already taken." } };
    const user = {
      username: "Tester",
      email: "user@realworld.io",
      password: "mysupersecretpassword"
    };

    cy.server();
    cy.route({
      url: "**/api/users",
      method: "POST",
      status: 422,
      response
    }).as("signup-request");

    cy.visit(paths.register);

    cy.window()
      .its("appActions")
      .invoke("signup", user);

    cy.wait("@signup-request");

    Object.entries(response.errors).map(([subject, error]) => {
      cy.findByText(`${subject} ${error}`).should("be.visible");
    });
  });
});

Almost all the code of the tests is the same. Keeping in mind that we must resist adding complex abstractions to the code of the tests (see the testing rules) we could separate some code to a dedicated function and leverage it from the body of the test. After all, only the response changes between the above tests... Take a look at the following code

File: cypress/integration/examplessignup-integration/signup-error-paths-2.integration.spec.js

/// <reference types="Cypress" />

import { paths } from "../../../../realworld/frontend/src/components/App";

context("Signup flow", () => {
  const testBody = response => {
    const user = {
      username: "Tester",
      email: "user@realworld.io",
      password: "mysupersecretpassword"
    };

    cy.server();
    cy.route({
      url: "**/api/users",
      method: "POST",
      status: 422,
      response
    }).as("signup-request");

    cy.visit(paths.register);

    cy.window()
      .its("appActions")
      .invoke("signup", user);

    cy.wait("@signup-request");

    Object.entries(response.errors).map(([subject, error]) => {
      cy.findByText(`${subject} ${error}`).should("be.visible");
    });
  };

  it("Should show an error if the back-end report that the email has already been used", () => {
    testBody({ errors: { email: "is already taken." } });
  });

  it("Should show some errors if the back-end reports that some data has already been used", () => {
    testBody({ errors: { email: "is already taken." } });
  });

  it("Should show all the errors reported by the back-end as they are", () => {
    testBody({ errors: { foo: "bar", other: "problems" } });
  });
});

The last test checks that the front-end prints the errors as they are, so we are sure that the errors showed to the user are completely driven by the back-end.

Please note that Cypress has other interesting options to simulate network behaviors, like the delay option or using a function as the response, etc.

With the recent examples, it should be clear that E2E testing is good but not practical at all! So, write a few E2E tests (just for the happy paths) and concentrate on the UI Integration Tests.

Author: Stefano Magni

results matching ""

    No results matching ""