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";
const headers = { "Access-Control-Allow-Origin": "*" }
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.intercept("POST", "**/api/users", { fixture: "users/signup", headers }).as("signup-request");
cy.intercept("GET", "**/api/tags", { fixture: "tags/empty-tags", headers }).as("tags");
cy.intercept("GET", "**/api/articles/feed**", { fixture: "articles/empty-articles", headers }).as("feed");
cy.visit(paths.register);
// form filling
cy.findByPlaceholderText(strings.username)
.type(user.username)
cy.findByPlaceholderText(strings.email)
.type(user.email)
cy.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(interception =>
expect(interception.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.
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.intercept
, its options contemplate the statusCode
option too. All we need to do is passing all exploded options to the cy.intercept
command
cy.intercept("POST", "**/api/users", {
body: { errors: { email: "is already taken." } },
statusCode: 422,
headers: { "Access-Control-Allow-Origin": "*" }
}).as("signup-request");
the url
, method
, and body
are not new, the statusCode
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.intercept("POST", "**/api/users", {
body: { errors: { email: "is already taken." } },
statusCode: 422,
headers: { "Access-Control-Allow-Origin": "*" }
}).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 body = { errors: { username: "is already taken.", email: "is already taken." } };
const user = {
username: "Tester",
email: "user@realworld.io",
password: "mysupersecretpassword"
};
cy.intercept("POST", "**/api/users", {
- body: { errors: { email: "is already taken." } }
+ body
statusCode: 422,
headers: { "Access-Control-Allow-Origin": "*" }
}).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(body.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 body = { errors: { email: "is already taken." } };
const user = {
username: "Tester",
email: "user@realworld.io",
password: "mysupersecretpassword"
};
cy.intercept("POST", "**/api/users", {
body,
statusCode: 422,
headers: { "Access-Control-Allow-Origin": "*" },
}).as("signup-request");
cy.visit(paths.register);
cy.window()
.its("appActions")
.invoke("signup", user);
cy.wait("@signup-request");
cy.findByText(`email ${body.errors.email}`).should("be.visible");
});
it("Should show some errors if the back-end reports that some data has already been used", () => {
const body = { errors: { username: "is already taken.", email: "is already taken." } };
const user = {
username: "Tester",
email: "user@realworld.io",
password: "mysupersecretpassword"
};
cy.intercept("POST", "**/api/users", {
body,
statusCode: 422,
headers: { "Access-Control-Allow-Origin": "*" }
}).as("signup-request");
cy.visit(paths.register);
cy.window()
.its("appActions")
.invoke("signup", user);
cy.wait("@signup-request");
Object.entries(body.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 = body => {
const user = {
username: "Tester",
email: "user@realworld.io",
password: "mysupersecretpassword"
};
cy.intercept("POST", "**/api/users", {
body,
statusCode: 422,
headers: { "Access-Control-Allow-Origin": "*" },
}).as("signup-request");
cy.visit(paths.register);
cy.window()
.its("appActions")
.invoke("signup", user);
cy.wait("@signup-request");
Object.entries(body.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, check out the cy.intercept documentation.
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