Writing end-to-end tests has gotten a lot easier to do with tools like Cypress and TestCafe. These two libraries are very similar. They both allow you to mock HTTP requests, although in slightly different ways. They both have a promise-based API, although Cypress has it's own "promise" in place.
I wanted to put together an article showing you what it looks like in both frameworks to mock an HTTP request and explain why you may want to use this practice. I also wanted to show you why you can't use async/await
for Cypress tests.
Writing Tests: TestCafe vs Cypress
Let's compare what writing a test looks like in Cypress and TestCafe. For some context, imagine we have a UI that consists of a list of products, with a text input used for filtering down the list. When a user types into the input, the list of products should immediately update to filter out products that don't match the user input.
In TestCafe, this test could look like this:
test('should properly filter products based on the query entered', async t => {
// Get the count of products on initial page load
const originalProductCount = await Selector(`[data-locator='individual-product']`).count;
// Type a string into the filter input, to filter out products that don't match
await t.typeText(Selector(`[data-locator='filter-input']`), 'Ben');
// Get the new count of visible products on the page
const countOfFilteredProducts = await Selector(`[data-locator='individual-product']`).count;
await t.expect(originalProductCount).notEql(countOfFilteredProducts);
await t.expect(countOfFilteredProducts).lt(originalProductCount);
await t.expect(countOfFilteredProducts).eql(2);
});
This same test in Cypress would look like this:
it('should properly filter products based on the query entered', () => {
// Get the count of products on initial page load
cy.get('[data-locator=individual-product]').then(products => {
let originalProductsCount = products.length;
// Type a string into the filter input, to filter out products that don't match
cy.get(`[data-locator='filter-input']`).type('Ben');
// Get the new count of visible products on the page
cy.get('[data-locator=individual-product]').then(filteredProducts => {
let filteredProductsCount = filteredProducts.length;
expect(originalProductsCount).not.to.equal(filteredProductsCount);
});
});
});
Promises in Cypress
You may have noticed that I'm not using async/await with the Cypress tests. Unfortunately, that is a trade-off when using Cypress. The cy
commands do not return actual promises, even though you can use .then(...)
on each command. The "promise" that is returned is a Chainer object, which is a wrapper of a real promise. They chose this design to make Cypress much more deterministic and stable, as normal JS promises have no concept of retries or retry-ability.
From the docs:
The Cypress API is not an exact 1:1 implementation of Promises. They have Promise like qualities and yet there are important differences you should be aware of.
- You cannot race or run multiple commands at the same time (in parallel).
- You cannot ‘accidentally’ forget to return or chain a command.
- You cannot add a .catch error handler to a failed command.
The downside is I can't do something like:
// async / await will not work
it('does something cool', async () => {
// `cy` commands will never return a useful value
const value = await cy.get('.product-list');
expect(value.length).to.equal.(10);
})
Instead, your code would look closer to this:
it('should have only 10 products', () => {
cy.get('.product-list')
.then(products => {
expect(products.length).to.equal.(10);
});
})
This should be very familiar since before async/await all of our promises were written this way.
Mocking HTTP Requests
Both TestCafe and Cypress provide a way to mock HTTP requests in your test. This can speed up your test runs since you won't have to wait for real server responses, which can sometimes take a while, depending on what your app is doing. You should still have a few tests that depend on actual server responses, like for testing login flow. But when testing more granular functionality you should mock some of your requests. I know it sounds weird to do that, but I think of it as I'm testing the UI right now, not the API. You most likely already have tests in place for your API and you don't need to test it twice. You may ask "But what if the API model changed? Won't my test fail now?". If the API changes, that most likely means your UI that handles the responses/requests would need to change as well, which means you probably need to update your tests anyways.
If mocking your HTTP requests still has you worried, you don't HAVE to do it. It's totally up to you and your team to decide what will bring you the most confidence in your tests.
Mocking in TestCafe
To mock HTTP requests in TestCafe, you would use the RequestMock
constructor.
// api-mocks.ts
import { RequestMock } from 'testcafe';
const mock = RequestMock()
.onRequestTo('https://some.domain.com/api/products')
.respond({ data: 'data from API' }, 200); // returns JSON response { data }, and 200 statusCode
export default mock;
Then import the mock into your test and use the fixture
or test
hook called requestHooks
:
// spec.ts
import { Selector } from 'testcafe';
import mock from './api-mocks';
fixture('A fixture')
.page(...)
.requestHooks(mock)
You can also chain multiple requests and responses to the mock:
// api-mocks.ts
import { RequestMock } from 'testcafe';
const mock = RequestMock()
.onRequestTo('https://some.domain.com/api/products')
.respond({ data: 'data from API' }, 200)
.onRequestTo('https://some.domain.com/api/users')
.respond(null, 204) // returns empty response
.onRequestTo('https://some.domain.com/api/products/123')
.respond((req, res) => {
// returns a custom response
res.statusCode = '200';
res.setBody({ data: { name: 'product name', id: 123 } });
});
export default mock;
Mocking in Cypress
Stubbing responses in Cypress is slightly different. To tell your tests to start stubbing specified requests, first, you need to call cy.server()
to enable it. To tell the server what requests to stub and what response to return you then call cy.route(<options>)
.
cy.server();
cy.route({
method: 'GET',
url: '/products',
response: [{ id: 1 }, { id: 2 }], // list of mock products.
});
You could also skip the route object and use parameters instead:
cy.server();
cy.route('GET', '/products', [{ id: 1 }, { id: 2 }]);
Cypress has this idea of fixtures
. Unlike TestCafe, Cypress fixtures
are JSON objects that hold the data you'd like to use in a mocked response. This is very useful since sometimes an API can return complex data, and having that in a separate file keeps your spec file clean. So instead of specifying a response inline within the cy.route
method, you can specify a fixture
to be used. All you need to do is create a new .json
file within the /cypress/fixtures/
directory.
// cypress/fixtures/products.json
[
{
id: 1,
name: 'Product A',
},
{
id: 2,
name: 'Product B',
},
];
// spec.js
it('Should display a list of products', () => {
cy.server();
cy.route('GET', '/api/products', 'fixture:products.json').as('getProducts');
cy.visit('/a-page-with-a-list-of-products');
cy.wait(['@getProducts']);
cy.queryByText('Product A').should('exist');
cy.queryByText('Product B').should('exist');
});
Conclusion
I hope this helps someone decide between what framework to use for end-to-end testing. I think both of these options here are great choices, and you can’t really go wrong. Mocking your applications HTTP requests can really speed up your test runs. You should still fully test the critical paths, but for the rest of your tests, you should mock. When you are mocking while using Cypress, I recommend utilizing fixtures to define your data. It just keeps things organized.
If you’re just getting started with end-to-end testing check out my course on Pluralsight, End-to-end Web Testing with TestCafe: Getting Started.
Thanks for reading :)
Follow me on twitter 😀