Question
I'm trying model based testing approach with Cypress. This means that all test cases are generated on the fly from provided "States" and "Events". States are checking properly and DOM events are triggered as expected, but I have trouble intercepting network request. What I need to do:
- Set request interception before I go to page, because first request is starting right after page loads
- Wait for model to execute event with appropriate response data
- During event execution supply response data to request and "unpause" it.
What I tried
I thought that if you just call cy.intercept
, request will pause until some other cy.intercept
with reply()
or continue()
will be called. I intercepted all routes in beforeAll
and tried it, but request simply proceeds with it's natural request lifecyce.
My second attempt was to return Promise from intercept handler. Documentation states that "If the handler returned a Promise, wait for the Promise to resolve.". Hovewer, when I call req.reply
before resolving this promise, Cypress throws and error: "req.reply()
was called after the request handler finished executing, but req.reply()
can not be called after the request has already completed."
Is there a way to completely pause all intercepted requests and resolve them when I need with status, body and headers I need?
Code
// App.tsx
import { useState, useEffect } from 'preact/hooks';
export function App() {
let [authorizationStatus, setAuthorizationStatus] = useState('unknown');
useEffect(function () {
fetch('/api/auth/v1').then(response => response.json()).then(body => {
if (body.authorization_status === 'OK') {
setAuthorizationStatus('authorized');
return;
}
setAuthorizationStatus('unauthorized');
});
}, []);
if (authorizationStatus === 'unknown') return <div data-testid="unknown"> Getting auth status </div>;
if (authorizationStatus === 'authorized') return <div data-testid="authorized">Congratulations, you are authorized</div>;
if (authorizationStatus === 'unauthorized') return <div data-testid="unauthorized">Sorry, but you don't have rights to be here</div>;
return null;
}
// cypress/integration/integration.spec.ts
import { createMachine } from "xstate";
import { createModel } from "@xstate/test";
let machine = createMachine(
{
id: "test-machine",
initial: "loading",
states: {
loading: {
meta: {
test() {
return new Cypress.Promise((resolve) => {
cy.get('[data-testid="unknown"]').then(() => resolve());
});
},
},
on: {
response: [
{ target: "authorized", cond: "is_authorized" },
{ target: "unauthorized" },
],
},
},
authorized: {
meta: {
test() {
return new Cypress.Promise((resolve) => {
cy.get('[data-testid="authorized"]').then(() => resolve());
});
},
},
},
unauthorized: {
meta: {
test() {
return new Cypress.Promise((resolve) => {
cy.get('[data-testid="unauthorized"]').then(() => resolve());
});
},
},
},
},
},
{
guards: {
is_authorized: (_, event) => event.authorization_status === "OK",
},
}
);
let routes = {
api: "/api/auth/v1",
};
beforeEach(() => {
Object.entries(routes).forEach(([name, url]) => {
// Here I want to pause request execution somehow.
// I'm ok with moving it somewhere else
cy.intercept(url).as(name);
});
});
let model = createModel(machine).withEvents({
response: {
exec(_, event) {
return new Cypress.Promise((resolve) => {
// Here I want to resume request with provided parameters
cy.intercept(routes.api, (req) => {
req.reply(event);
}).then(() => resolve());
});
},
cases: [{ authorization_status: "OK" }, { authorization_status: "NOT_OK" }],
},
});
let plans = model.getShortestPathPlans();
plans.forEach((plan) => {
describe(plan.description, () => {
plan.paths.forEach((path) => {
it(path.description, () => {
cy.visit("http://localhost:3000");
return new Cypress.Promise(async (resolve) => {
await path.test(cy);
resolve();
});
});
});
});
});
Update
I finally managed to make it work almost as I want, but still haven't found what I needed.
Based on Generated tests with XState and Cypress article by Tim Deschryver I got rid of all the promises inside tests and it started working a bit better, more like what I have been expecting.
Next thing to do were to move beforeEach
call inside suite of tests and intercept all requests based on events inside segments of the current path. I added route
key to event to indicate that this event should be intercepted and then added response body with which it have to be intercepted. It looks something like this (only relevant code):
let model = createModel<Cypress.cy>(machine).withEvents({
response: {
exec(cy, event) {
cy.wait(`@${event.route}`);
},
cases: [
{ route: "api", body: { authorization_status: "NOT_OK" } },
{ route: "api", body: { authorization_status: "OK" } },
],
},
});
let plans = model.getShortestPathPlans();
plans.forEach((plan) => {
describe(plan.description, () => {
plan.paths.forEach((path, i) => {
beforeEach(function () {
path.segments.forEach((segment) => {
if (!segment.event.route) return;
cy.intercept(routes[segment.event.route], {
body: segment.event.body,
statusCode: 200,
}).as(segment.event.route);
});
});
it(path.description, () => {
cy.visit("http://localhost:3000").then(async () => await path.test(cy));
});
});
});
});
In my application that I'm working on there is a bit more complex setup, but this is what made it work. After that I had to add default stubs for each route, because other way I will get Unexpected token < in JSON at position 0
when unstubbed route will fail. This will effectively fail the test (which is good on itself). And after I added default stubs, I had to add initial error handling which I planned to do properly later, because when Cypress reloads the page in the middle of request, I now get AbortError
.
Not sure is pausing request would help with errors (probably not), but I still don't like that I have to dig into internals of path
, parse it's segment and events to find routes and stub them beforehand. I'd prefer to pause everything and resolve with appropriate response once I have this response from my own code (in events exec function).