Cypress fixtures vs tasks vs intercepts: when each one actually fits
Cypress has three features that look similar but solve completely different problems. Fixtures are static data. Tasks are Node-side escape hatches. Intercepts are network shape. Mixing them up is how teams end up with seed data in fixtures and migrations in cy.task. Here's the boundary.
Fixtures: static data, not seed data
Fixtures in Cypress are JSON (or JS, or text) files in cypress/fixtures/. When you call cy.fixture('users.json'), Cypress loads that file into the browser context and gives you the object. That's all it does.
// Load a fixture into the test
cy.fixture('products.json').then((products) => {
// products is the parsed JSON object
expect(products).to.have.length(10);
});
// Use directly in cy.intercept as a stubbed response
cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('products');What fixtures are for: test inputs with known shape. A list of users you'll iterate over in a test. A product catalogue to feed a stub response. A JSON schema you'll validate against. Content that is stable, reusable across multiple tests, and doesn't change between runs.
What fixtures are NOT for: anything dynamic. If you need a fresh user record per test, a fixture can't help you — a fixture is the same file every time. If you need to insert a row into a real database before a test runs, a fixture has no mechanism to do that. If you need the response to vary by test scenario, a fixture is the wrong tool.
Fixtures earn their keep when the same static shape appears in many tests. A 200-row product catalogue, a fixed API error schema, a set of test addresses for a form — these are fixture territory. A user created fresh per test is not.
cy.task: the Node-side escape hatch
Cypress runs your test code in the browser. Most of the time that's fine. Sometimes you need to do something that only works in Node.js: query a database, read a file with fs, call an internal API without CORS restrictions, generate a cryptographic value. That's cy.task.
Tasks are registered in cypress.config.ts and run in the Node.js process:
// cypress.config.ts
import { defineConfig } from 'cypress';
import { db } from './src/lib/db';
export default defineConfig({
e2e: {
setupNodeEvents(on) {
on('task', {
async createUser(overrides: Record<string, unknown>) {
const user = await db.users.create({
email: `test+${Date.now()}@example.com`,
role: 'viewer',
...overrides,
});
return user;
},
async deleteUser(id: string) {
await db.users.delete(id);
return null; // cy.task must return null or a serialisable value
},
readEnv(key: string) {
return process.env[key] ?? null;
},
});
},
},
});In a test:
cy.task('createUser', { role: 'admin' }).as('adminUser');
cy.get('@adminUser').then((user) => {
cy.visit(`/admin/users/${user.id}`);
});The rule of thumb: if you'd need to run it on a server, it's a task. Database mutations, filesystem access, environment variable reads, spawning a subprocess — all tasks.
One constraint worth knowing: cy.task must return null or a JSON-serialisable value. You can't return a class instance, a function, or a circular structure. This is because the return value crosses the Node↔browser boundary and is serialised through JSON.
Tasks are also the right tool for cleanup. If your test creates a user, delete that user in an after or afterEach hook using another task. Leaving orphaned test data in a shared environment is the most common source of test-order dependencies.
cy.intercept: network-shape control
If fixtures are static data and tasks are Node-side code, intercepts are network-layer control. Full treatment is in the cy.intercept post — the short version here is that cy.intercept sits between your application code and the network and lets you spy on, modify, or replace HTTP traffic.
// Spy on a request (let it through, watch it)
cy.intercept('GET', '/api/orders').as('orders');
// Stub a response (block it, return your own)
cy.intercept('GET', '/api/orders', { fixture: 'orders.json' }).as('orders');
// Transform a real response
cy.intercept('GET', '/api/orders', (req) => {
req.continue((res) => {
res.body.items = res.body.items.slice(0, 3);
});
}).as('orders');Notice that cy.intercept can use a fixture — this is the one place where fixtures and intercepts interact. The fixture provides the response body shape; the intercept controls when and how that shape is returned.
The three-feature decision tree
When you're writing a test and need data or state, start here:
Is the data static and known at test-write time? → Fixture. Put it in cypress/fixtures/, reference it by name.
Does the test need to read or mutate real server-side state? → Task. Register a Node.js function in cypress.config.ts, call it with cy.task('name', payload).
Does the test need to control what the network returns? → Intercept. Use cy.intercept with an alias and cy.wait('@alias') after actions that trigger the network call.
These three don't compete. A single test can use all three:
// Task: create a real record in the DB
cy.task('createOrder', { total: 99.99 }).as('order');
// Intercept: control the API shape the browser sees
cy.intercept('GET', '/api/orders/*', { fixture: 'order-detail.json' }).as('orderDetail');
// Fixture: provide the stub response shape (via intercept)
cy.get('@order').then(({ id }) => cy.visit(`/orders/${id}`));
cy.wait('@orderDetail');
cy.get('[data-testid="total"]').should('have.text', '£99.99');The task created the real record (so the route exists). The intercept controlled the GET response (so the UI shape is predictable). The fixture provided the response body. Each tool did exactly one job.
Common anti-patterns
Seed data in fixtures — a cypress/fixtures/test-user.json file used to "seed" a user before each test. The JSON is loaded, then a task inserts it. The fixture adds a step without adding value; just call the task directly.
Using cy.task to fetch real APIs — cy.task('fetchOrderFromApi', id) that hits a live endpoint from Node.js. This creates a test that calls the API twice: once from the task, once from the browser. You've added a redundant call and made the test harder to debug. Let the browser make the call; intercept it if you need to control the response.
Intercepts as fixtures — using cy.intercept as the primary data store, stubbing every endpoint with fixtures for every test. This gives you a test suite that tests your own fake data. Some stubs are fine; a total stub of all network traffic means you're not testing your app's real data flow. Keep stubs focused on what the test actually needs to control.
The fixture/task/intercept distinction clarifies most data-management decisions in Cypress. Once you know which tool fits which problem, the test design gets simpler — and the support file stays smaller.
// related
cy.intercept the right way: aliases, stubs, and the bug it usually catches
cy.intercept is the most powerful command in Cypress and the one teams most often misuse. Here's the playbook: when to alias, when to stub, when to spy, and the race-condition-shaped bug that intercepts usually catch.
Custom Cypress commands that actually pay off
Most teams over-abstract too early. Four custom commands are worth writing on every Cypress project — login, seed, intercept, visit. The rest can wait.