Dynamic XHR responses recording & stubbing with Cypress

Cypress is an amazing tests runner for end-to-end testing. Here at AX2, we’re in the process of integrating it in our workflow, along with Jest that we use for unit testing.

One of the things we liked most about Cypress at first glance was how easy it is to get started with, whereas other e2e tests runners out there might be a bit intimidating because they require a more complex configuration before being functional. To start testing with Cypress, you just need to install the package, and you’re good to go: Cypress’ binary will be installed and the basic files structure that Cypress requires will be created in your project.

One powerful feature in Cypress is the ability to stub XHR responses. This means that when your app fetches data from an API, you can intercept that request and let Cypress respond to it with local data from a JSON file. This is very useful to keep consistency from one test to the other.

You don’t necessarily want to use this feature because not stubbing XHR responses also lets you test your API, which is closer to a real user experience. But if your focus is on testing the app itself and you don’t want to deal with potential issues API-side (i.e. the API is down or responds slowly) then this is a great option. Read more about stubbing the backend here.

So basically, if you wanted to do a simple XHR stub, you would do something like this:

describe('MyApp', () => {
it('Works', () => {
  // Start Cypress' server to hook into XHR requests
cy.server()
  // Override calls to URLs starting with activities/ and use the
// content of activities.json as the response
cy.route('GET', 'activities/*', 'fixture:activities.json')
});

Pretty simple! All you have to do is start Cypress’ server by calling cy.server() and define some routes to be intercepted and answered with JSON from a local file.

But what if your app makes dozens of XHR calls, each with their specific parameters and responses? You would have to manually download your API’s responses and define a route for each of them in your tests. And if you were to change the way URLs are formatted, or if the data from the API changes, you would need to update all these fixtures by hand… pretty laborious.

We were in that exact situation and we thought we needed a way to perform a “record run” of our tests, where the API would be called for real and we would save every response locally in order to use those as fixtures for subsequent tests.

Thankfully, Cypress has all you need to do that:

  • cy.writeFile() lets you write data to a file anywhere in the project’s directory
  • cy.server() lets you hook into any XHR call and catch its response
  • cy.fixture() returns a promise-like callback into which you can define routes using cy.route()
  • When running Cypress, you can specify environment variables with the 
    -e flag (we use this to toggle between record and normal mode)

That’s pretty much all you need. Let’s get to it!


The first thing you’ll want to do is setup some scripts in your package.json:

{
"scripts": {
"cy:record": "cypress run --env RECORD=1",
"cy:run": "cypress run"
}
}

As you can see, we defined a cy:record script which runs Cypress with a RECORD environment variable set to 1, while cy:run simply runs Cypress normally (for when we’ll use our fixture).

Now, let’s handle our “record mode”. We need to hook into all XHR fetches and save that data locally:

describe('MyApp', () => {
// We declare an empty array to gather XHR responses
const xhrData = [];
  it('Works', () => {
cy.server({
// Here we handle all requests passing through Cypress' server
onResponse: (response) => {
if (Cypress.env('RECORD')) {
const url = response.url;
const method = response.method;
const data = response.body;
// We push a new entry into the xhrData array
xhrData.push({ url, method, data });
}
},
});
    // This tells Cypress to hook into any GET request
if (Cypress.env('RECORD')) {
cy.route({
method: 'GET',
url: '*',
});
}
});
});

Note how we used the RECORD env variable to define behaviours specific to the record mode. With the code above, any GET request that our app performs is passed through Cypress’ server where we can catch the response and append it to an array that we declare before the actual tests. For each request, we also keep the full URL, which we’ll use later when defining our routes; we also keep the request’s method, which might prove useful if we wanted to stub other requests than GETs.

Now to actually store the data locally, we hook into after() and call cy.writeFile() from there:

after(() => {
// In record mode, save gathered XHR data to local JSON file
if (Cypress.env('RECORD')) {
const path = './cypress/fixtures/fixture.json';
cy.writeFile(path, xhrData);
}
});

Running cy:record should result in a fixture.json file being created in the fixtures/ directory.

Example of a “record run”, notice the “WRITEFILE” log at the end
You might be tempted to write XHR responses in Cypress’ server onResponse() hook, but this would result in Cypress returning an error: “Cypress detected that you returned a promise from a command while also invoking one or more cy commands in that promise.”
Writing the data in the
after() hook circumvents this problem (and is probably a better option anyway).

All that’s left to do is to load our fixtures when running the tests in normal mode:

if (!Cypress.env('RECORD')) {
cy.fixture('fixture').then((data) => {
for (let i = 0, length = data.length; i < length; i++) {
cy.route(data[i].method, data[i].url, data[i].data);
}
});
}

We load the contents offixture.json and iterate over it to set a route for every entry. Now, when running cy:run, all GET requests will be stubbed using data from fixture.json!

Example of a “stubbed run”, notice how the GET request is marked as a XHR STUB

Here are all the pieces of code put together:

describe('MyApp', () => {
// We declare an empty array to gather XHR responses
const xhrData = [];
  after(() => {
// In record mode, save gathered XHR data to local JSON file
if (Cypress.env('RECORD')) {
const path = './cypress/fixtures/fixture.json';
cy.writeFile(path, xhrData);
}
});
  it('Works', () => {
cy.server({
// Here we handle all requests passing through Cypress' server
onResponse: (response) => {
if (Cypress.env('RECORD')) {
const url = response.url;
const method = response.method;
const data = response.body;
// We push a new entry into the xhrData array
xhrData.push({ url, method, data });
}
},
});
    // This tells Cypress to hook into any GET request
if (Cypress.env('RECORD')) {
cy.route({
method: 'GET',
url: '*',
});
}
    if (!Cypress.env('RECORD')) {
cy.fixture('fixture').then((data) => {
for (let i = 0, length = data.length; i < length; i++) {
cy.route(data[i].method, data[i].url, data[i].data);
}
});
}
    // Your test scenario here
});
});

Conclusion

If you want to try this out, we made a small repo with a super-basic test scenario that stubs a single XHR GET request: https://github.com/ax2inc/cypress-xhr-responses-recording

If you did something similar or if you have ideas on how this could be improved, please share your thoughts with us!

Also, it looks like Cypress’ team is working on the Plugins API. It would be interesting to see if it evolves to a point where all this recording & stubbing process could be bundled as a plugin to make it even easier to implement this behaviour in test scenarios.