Stubbing the Back-end
Explore how to implement UI integration tests using Cypress by stubbing back-end AJAX calls with static responses. Understand the benefits of faster tests, front-end testing independence, and the ability to simulate edge cases without relying on a real back-end.
We'll cover the following...
What’s the matter with E2E tests? Well, they:
-
They are slow: in the previous chapter, we dedicated a lot of attention to reducing the test duration but still they are slow.
-
They do not allow the front-ender to work without a back-ender which greatly limits testing.
-
They make edge cases replication difficult.
E2E tests are not feasible for front-end testing. Although they are important we cannot rely on them too much. This is why E2E testing is at the top of the testing pyramid. They give you the most confidence of any test, but they are very expensive in terms of writing, maintenance, and stability.
Going down the testing pyramid we can also find integration tests (where a part of the application is tested) and unit testing (a single unit/module test). Cypress allows us to write another test type easily: UI Integration Tests. The goal is to test the entire front-end app but without a real back-end. All the AJAX requests are stubbed with static responses. The main advantages are:
-
Extreme speed. Cypress responds to the front-end AJAX requests in hundredths of a second.
-
Front-end testing independence. We test the front-end while developing the front-end, meaning we do not need to delay the front-end testing due to the back-end.
-
Testing confidence: testing the whole front-end in a browser gives you more confidence than a JSDom test with the terminal.
-
Edge cases replication. With static responses, you can simulate (or reproduce, if you’re analyzing a bug) every edge case in a while.
Please note an important terminology difference:
-
A stub is a static response used to “replace” the server.
-
A mock is a simplified version of the back-end. It has the same APIs (from a front-end perspective) but with the minimum complexity needed to simulate the real back-end functionalities.
Implementing the first UI Integration test
Let’s analyze the signup flow.
Our E2E signup flow is as follows :
To transform our above signup flow into a UI Integration test, the steps are as follows:
- We no longer need a random user. We randomized the user data to improve the test determinism and avoid registering the same user twice.
-const random = Math.floor(Math.random() * 100000);
const user = {
- username: `Tester${random}`,
+ username: "Tester",
- email: `user+${random}@realworld.io`,
+ email: "user@realworld.io",
password: "mysupersecretpassword"
};
- We Cypress to intercept every AJAX request and respond with a static response. To do this, we use another feature of the
cy.interceptcommand, introduced with the Waiting for an AJAX request chapter: passing a response.
-cy.intercept("POST", "**/api/users").as("signup-request");
+cy.intercept("POST", "**/api/users", {
+ headers: { "Access-Control-Allow-Origin": "*" },
+ body: {
+ user: {
+ username: "Tester",
+ email: "user@realworld.io",
+ token:
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVkN2ZhZjc4YTkzNGFiMDRhZjRhMzE0MCIsInVzZXJuYW1lIjoidGVzdGVyNzk1MzYiLCJleHAiOjE1NzM4MzY2ODAsImlhdCI6MTU2ODY0OTA4MH0.zcHxMz2Vx5h-EoiUZlRyUw0z_A_6AIZ0LzQgROvsPqw"
+ }
+ }
+}).as("signup-request");
Please note that we must consider CORS as well. Without setting the headers with headers: { "Access-Control-Allow-Origin": "*" }, the browser will block the request as localhost:4100 can’t perform XHR requests to localhost:3100 without proper CORS management.
- We need to stub AJAX calls because, once the signup flow is complete, the home page will call more APIs to populate the page.
The test Runner helps us identify the unstubbed AJAX calls.
As you can see:
- The first AJAX call is a
POSTto/api/users. It’s stubbed and the alias issignup-request. - The second AJAX call is a
GETto/api/tagsand it’s unstubbed (it hits the back-end application). - The third AJAX call is a
GETto/api/articles/feedand it’s unstubbed.
The Test Runner helps teach us why the front-end behaves in unexpected ways.
We can stub both the second and the third AJAX calls:
cy.intercept("GET", "**/api/tags", { body: { tags: [] }, headers: { "Access-Control-Allow-Origin": "*" }, }).as("tags");
cy.intercept("GET", "**/api/articles/feed**", {
body: {
articles: [],
articlesCount: 0
},
headers: { "Access-Control-Allow-Origin": "*" }
}).as("feed");
- We do not need to check the AJAX response payload anymore (because Cypress responds in place of a back-end app).
-expect(interception.response.statusCode).to.equal(200);
-cy.wrap(interception.response.body)
- .should("have.property", "user")
- .and(
- user =>
- expect(user)
- .to.have.property("token")
- .and.to.be.a("string").and.not.to.be.empty
- )
- .and("deep.include", {
- username: user.username.toLowerCase(),
- email: user.email
- });
- We should wait for the two AJAX calls.
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"]);
And we’re done! You can take a look at the whole test and run it.
Note: You can see the Cypress UI better by opening the link next to Your app can be found at:
import { paths } from "/educative-cypress-course/realworld/frontend/src/components/App";
import { noArticles } from "/educative-cypress-course/realworld/frontend/src/components/ArticleList";
import { strings } from "/educative-cypress-course/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", {
user: {
username: "Tester",
email: "user@realworld.io",
token:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVkN2ZhZjc4YTkzNGFiMDRhZjRhMzE0MCIsInVzZXJuYW1lIjoidGVzdGVyNzk1MzYiLCJleHAiOjE1NzM4MzY2ODAsImlhdCI6MTU2ODY0OTA4MH0.zcHxMz2Vx5h-EoiUZlRyUw0z_A_6AIZ0LzQgROvsPqw"
}
}).as("signup-request");
cy.route("GET", "**/api/tags", { tags: [] }).as("tags");
cy.route("GET", "**/api/articles/feed**", { articles: [], articlesCount: 0 }).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(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");
});
});Why should we test the request payload in the UI Integration Tests too? Leveraging the fact that the requests do not hit the back-end, we are going to use these kinds of tests in many different paths. Some of these paths will not have a corresponding E2E test because they would be too expensive. Since request payloads are important, we shouldn’t check only the ones included in an E2E test.