Back to Blog
On this page5 sections

// deep dive

Cypress fixtures vs tasks vs intercepts: when each one actually fits

qa.codesqa.codes · 27 February 2026 · 9 min read
Intermediate
cypresstypescriptfixtures

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 APIscy.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

Tutorials·10 May 2026 · 7 min read

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.

cypresstypescriptpatterns