The previous lecture decided when to stub. This one is about how to organise the stubs, because mock sprawl becomes its own maintenance burden faster than most teams expect.
The shape of the problem: a contact object appears in eleven fixture files. The API adds a required field. You now have eleven files to update, and you will miss two, and those two tests will keep passing against a shape the server no longer produces.
Build fixtures from factories, not from files.
The instinct is to write JSON files. The better pattern is a function that produces the object, with overrides:
// support/factories/contact.js
export const aContact = (overrides = {}) => ({
id: 'c-1',
name: 'Dana Fox',
company: 'FoxLabs',
email: 'dana@foxlabs.test',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
...overrides,
});
One definition. When the API adds a field, you add it once. Tests that need a variation ask for it:
cy.intercept('GET', '/api/contacts', {
body: [aContact(), aContact({ id: 'c-2', name: 'Sam Reyes', status: 'archived' })],
});
Read what that gains you beyond the single edit point. The test now states only what matters to it. A reader sees "one active contact, one archived" rather than forty lines of JSON in which the meaningful difference is buried.
Scenario builders for the common states.
Above factories sits a thin layer naming the states your suite keeps needing:
// support/scenarios/contacts.js
export const contactsScenarios = {
empty: () => cy.intercept('GET', '/api/contacts', { body: [] }).as('contacts'),
many: (n = 500) =>
cy.intercept('GET', '/api/contacts', {
body: Array.from({ length: n }, (_, i) => aContact({ id: `c-${i}`, name: `Contact ${i}` })),
}).as('contacts'),
serverError: () =>
cy.intercept('GET', '/api/contacts', { statusCode: 500, body: { error: 'Server error' } }).as('contacts'),
slow: () =>
cy.intercept('GET', '/api/contacts', (req) => req.reply({ delay: 3000, body: [aContact()] })).as('contacts'),
};
Now a test reads as its intent:
it('shows the empty state', () => {
contactsScenarios.empty();
cy.visit('/contacts');
cy.get('[data-testid="empty-state"]').should('be.visible');
});
Keeping stubs honest. The drift risk from the previous lecture is real, and there are two practical defences at this layer.
Generate the base object from the schema. If your API publishes an OpenAPI spec, generating factory defaults from it means a schema change updates every mock at once. This is the strongest defence available and it is worth the setup cost on a large suite.
Add a contract test. One test that calls the real endpoint and asserts the response has the shape your factory produces. It is cheap, it runs in seconds, and it fails the day the API changes, which is precisely when you want to know.
it('the contacts API still matches our fixtures [contract]', () => {
cy.request('/api/contacts').its('body.0').then((real) => {
expect(Object.keys(real).sort()).to.deep.equal(Object.keys(aContact()).sort());
});
});
That single test would have caught the renamed field in the previous lecture's story.
Where to draw the line. Not every stub needs a factory. A one-off 403 response inline in a single test is fine and clearer than an abstraction. The rule I use: the second time an object shape appears, it becomes a factory. Not the first, not the fifth.
Remember this: factories over files, scenario builders for named states, and one contract test so drift is loud.
Reference: cy.intercept.