Testing Fetch cancellation with Cypress
When it comes to make AJAX requests in plain JavaScript or React/Vue applications, most developers prefer to use full-fledged clients like axios, which has a lot of nice utils, like the interceptor.
However, there are situations where axios doesn't fit. For example, being based on XMLHttpRequest
, axios does not support streaming responses, whereas Fetch does. In Fetch, response.body
is a ReadableStream
. This makes Fetch appealing in a lot of situations.
In recent years, Fetch gained also the ability to abort requests with a signal. Let's see what it means to abort a request, and how to test such a thing in Cypress.
Aborting a Fetch request
To abort a Fetch request, we can pass a signal to the request init object. The following code shows an example:
const start = document.getElementById("start");
const stop = document.getElementById("stop");
const controller = new AbortController();
const signal = controller.signal;
async function fetchData(url, requestInit) {
const response = await fetch(url, requestInit);
return await response.json();
}
start.addEventListener("click", async () => {
await fetchData("/some-url/", { signal });
});
stop.addEventListener("click", () => {
controller.abort();
});
Here we create an AbortController
, extract the signal from it, and pass the signal to the fetcher function:
const controller = new AbortController();
const signal = controller.signal;
async function fetchData(url, requestInit) {
const response = await fetch(url, requestInit);
return await response.json();
}
In the frontend we have two buttons:
- one starts the AJAX request by calling
fetchData
- another button aborts the request by calling
controller.abort()
:
start.addEventListener("click", async () => {
await fetchData("/some-url/", { signal });
});
stop.addEventListener("click", () => {
controller.abort();
});
As for the frontend, here a simple HTML to drive our JavaScript code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Testing Fetch abort with Cypress</title>
</head>
<body>
<button id="start">START REQUEST</button>
<button id="stop">ABORT REQUEST</button>
</body>
<script src="index.js"></script>
</html>
The ability to abort a request is useful in a lot of situations. For example, when it comes to returning large amount of data from the backend, we may want to give the user the ability to abort any ongoing request in case she loses interest in the response.
In a functional test, we want to test such situation to ensure that requests are aborted when they should. In the next section, we see how to test such a scenario with Cypress.
Testing a Fetch request abort with Cypress
Writing a functional test for a Fetch request abort with Cypress consist in the following steps:
- preparing an interceptor for the request
- triggering the request in the frontend
- triggering the abort
- asserting that the request has been actually cancelled
At first, it could be a bit tricky to understand how to test whether the request has been aborted or not, but console.error()
can help, as we will see in a moment.
Let's get started.
Preparing an interceptor for the request
To start off with the test we need to prepare a Cypress request interceptor:
describe("Testing Fetch abort with Cypress", () => {
it("can abort an ongoing request", () => {
cy.intercept("GET", "/some-url/", {
statusCode: 200,
body: {},
delay: 200
});
cy.visit("");
});
});
Here we intercept any call to /some-url/
by adding also a slight delay to the response with the delay
option.
The delay is important to ensure that in the test we actually have the time to abort the request, otherwise the response from the interceptor would come too fast.
Triggering the request and the request cancellation in the frontend
To test the Fetch abort behaviour, we need to trigger the request, and the cancellation from the frontend. In our case we have some simple JavaScript code in place for this.
In our functional test we can simply select the start request/abort request buttons, as in the following code:
describe("Testing Fetch abort with Cypress", () => {
it("aborts an ongoing request", () => {
cy.intercept("GET", "/some-url/", {
statusCode: 200,
body: {},
delay: 200
});
cy.visit("");
cy.contains(/start request/i).click();
cy.contains(/abort request/i).click();
});
});
Now comes the interesting part. If we run this Cypress test, we should see the following error:
(uncaught exception) AbortError: The user aborted a request.
This exception comes from the Fetch cancellation, and it needs to be handled. Where to handle this exception depends on your use case, but for our example we can wrap the API call in try/catch
:
// ...
start.addEventListener("click", async () => {
try {
await fetchData("/some-url/", { signal });
} catch (err) {
//
}
});
// ...
Since we are going to handle a particular type of exception, AbortError
, we can extract the string in a constant:
const ABORT_ERROR = "AbortError";
// ...
start.addEventListener("click", async () => {
try {
await fetchData("/some-url/", { signal });
} catch (err) {
//
}
});
Finally, we can use whatever construct we like to process the error. In this example I'm using a switch
statement, with a console.error()
:
const ABORT_ERROR = "AbortError";
// ...
start.addEventListener("click", async () => {
try {
await fetchData("/some-url/", { signal });
} catch (err) {
switch (err.name) {
case ABORT_ERROR:
// Also, send the error to your monitoring system.
return console.error(err.message);
default:
return;
}
}
});
// ...
With this code in place we are now ready to test the request cancellation by "spying" on console.error()
calls.
Let's see how.
Note: We may argue that using console.error()
or console.log()
to dispatch errors is a bit simplistic. In a real-world app you would likely send out exceptions to the error tracking system of choice, and in that case you would spy on something other than console.error()
, but for the scope of this post we are good!
Asserting that the request has been actually cancelled
Spying in testing terminology means recording calls on a given function, in order to assert on the caller arguments, and on the number of calls.
To spy on console.error()
in Cypress we can get hang on window
, set a spy on console
, and save the spy with an alias. Here's how:
cy.window().then(win => {
cy.spy(win.console, "error").as("consoleErrSpy");
});
These spies should go in our functional test, right before invoking the request/cancellation:
describe("Testing Fetch abort with Cypress", () => {
it("aborts an ongoing request", () => {
cy.intercept("GET", "/some-url/", {
statusCode: 200,
body: {},
delay: 200
});
cy.visit("");
cy.window().then(win => {
cy.spy(win.console, "error").as("consoleErrSpy");
});
cy.contains(/start request/i).click();
cy.contains(/abort request/i).click();
});
});
Setting up the spy gives us the ability to assert on its arguments of invocation. To do this in our case we have two options.
If console.error()
is called only once in the piece of code under test (mostly unlikely since there can be any number of errors with an API call) we can use Cypress should()
like so:
cy.get("@consoleErrSpy").should(
"have.been.calledWith",
"The user aborted a request."
);
Here's the complete test:
describe("Testing Fetch abort with Cypress", () => {
it("can abort an ongoing request", () => {
cy.intercept("GET", "/some-url/", {
statusCode: 200,
body: {},
delay: 200
});
cy.visit("");
cy.window().then(win => {
cy.spy(win.console, "error").as("consoleErrSpy");
});
cy.contains(/start request/i).click();
cy.contains(/abort request/i).click();
cy.get("@consoleErrSpy").should(
"have.been.calledWith",
"The user aborted a request."
);
});
});
Instead, if we expect multiple call to console.error()
, we can directly access the spy object calls, with their arguments:
cy.get("@consoleErrSpy")
.its("firstCall")
.its("lastArg")
.should("eq", "The user aborted a request.");
Which method to use depends exclusively on the specific situation.
To close full circle here's the complete test:
describe("Testing Fetch abort with Cypress", () => {
it("can abort an ongoing request", () => {
cy.intercept("GET", "/some-url/", {
statusCode: 200,
body: {},
delay: 200
});
cy.visit("");
cy.window().then(win => {
cy.spy(win.console, "error").as("consoleErrSpy");
});
cy.contains(/start request/i).click();
cy.contains(/abort request/i).click();
cy.get("@consoleErrSpy").should(
"have.been.calledWith",
"The user aborted a request."
);
cy.get("@consoleErrSpy")
.its("firstCall")
.its("lastArg")
.should("eq", "The user aborted a request.");
});
});
Conclusion
In this post we saw how to test a Fetch request cancellation with Cypress. The process narrows down to the following steps:
- preparing an interceptor for the request
- triggering the request in the frontend
- triggering the abort
- asserting that the request has been actually cancelled by spying on a method
In a real-world app you would likely send out exceptions to the error tracking system of choice, and in that case you would spy on something other than console.error()
, but the broader concept stays the same.
For a full explanation on Fetch aborts check also Abortable Fetch by Jake Archibald.
For a complete explanation on cy.spy()
see spy.
Thanks for reading!