Skip to main content
Back to Blog
Guide
2026-08-08

Cypress Fixtures Dynamic Generation: Repeatable Data Without Brittle JSON

Master Cypress fixtures dynamic generation with seeded factories, API setup, and intercepts that deliver repeatable data without brittle test files.

Cypress Fixtures Dynamic Generation: Repeatable Data Without Brittle JSON

Cypress fixtures dynamic generation means building scenario data at test time from deterministic factories, then delivering it through an API, a network intercept, or a temporary file. The reliable pattern is not random data everywhere. It is a small valid baseline, explicit scenario overrides, a reproducible seed, and cleanup owned by the test. That combination gives each test meaningful variation while keeping every failure replayable.

Use static fixture files for immutable examples such as a known webhook payload or a tiny image. Generate data when a scenario needs unique identities, boundary values, relationships, or multiple states. Cypress already provides the pieces: ordinary TypeScript factories, cy.request() for server-side setup, cy.intercept() for controlled browser responses, cy.task() for Node-side work, and cy.readFile() when a file truly must change during a run. Teams comparing that toolbox with other runners can start with the JavaScript testing frameworks guide, while locator decisions for generated records benefit from the Playwright locator best practices.

The objective is a data contract that is expressive in a test and boring in CI. A good test should read like makeUser({ plan: 'enterprise' }), not like a 70-field JSON object, and its failure output should include enough seed and identity information to reproduce the exact record.

Choose the generation boundary before writing a factory

“Dynamic fixture” can describe several different mechanisms. They have different costs and different fidelity. Pick the lowest boundary that exercises the behavior under test.

Generation boundaryData reaches the application throughBest fitMain tradeoff
In-memory objectcy.intercept() response bodyUI states, uncommon server replies, deterministic errorsDoes not validate persistence or backend serialization
HTTP setup requestReal test-support or product APIEnd-to-end flows that need committed recordsRequires cleanup and an isolated environment
Node taskcy.task() calls a database or file helperSetup unavailable through HTTP, binary creationCouples test infrastructure to server-side code
Mutable filecy.writeFile() then cy.readFile()Import/export and file-driven workflowsDisk state can leak unless paths and cleanup are controlled
Static fixturecy.fixture() or intercept fixture propertyCanonical payloads that never changeBecomes repetitive when scenarios differ by many fields

For a profile card that only needs to render a suspended user, intercept an object. For an account deletion journey where several services must observe the same account, create the account through an API. For CSV import, generate an actual CSV file because parsing is part of the contract. Dynamic generation is an architectural choice, not a synonym for a faker library.

Keep generation outside the Cypress command queue where possible. A pure function can be unit tested, called synchronously while registering an intercept, and reused by API helpers. Cypress commands should orchestrate browser and server boundaries rather than hide ordinary object construction.

Build a typed baseline with explicit overrides

A factory should always produce a valid domain object. Callers override only the properties that matter to the scenario. The following module is deterministic without any dependency and returns a fresh nested object on every call.

// cypress/support/factories/user.ts
export type User = {
  id: string;
  email: string;
  displayName: string;
  plan: 'free' | 'team' | 'enterprise';
  status: 'active' | 'suspended';
  preferences: {
    locale: 'en' | 'fr';
    digest: boolean;
  };
};

let sequence = 0;

type UserOverrides = Omit<Partial<User>, 'preferences'> & {
  preferences?: Partial<User['preferences']>;
};

export function resetUserSequence(): void {
  sequence = 0;
}

export function makeUser(overrides: UserOverrides = {}): User {
  sequence += 1;
  const id = 'usr-' + String(sequence).padStart(4, '0');
  const preferences = {
    locale: 'en' as const,
    digest: true,
    ...overrides.preferences,
  };

  return {
    id,
    email: id + '@example.test',
    displayName: 'Test User ' + sequence,
    plan: 'free',
    status: 'active',
    ...overrides,
    preferences,
  };
}

The nested merge is deliberate. A shallow spread alone would replace all preferences when a caller supplies only { digest: false }. For deeply nested contracts, prefer smaller domain factories over a generic recursive merge. Recursive merge behavior around arrays, null, dates, and class instances is easy to misunderstand.

Reset the sequence at the test boundary if predictable IDs matter:

// cypress/e2e/profile.cy.ts
import { makeUser, resetUserSequence } from '../support/factories/user';

describe('profile', () => {
  beforeEach(() => {
    resetUserSequence();
  });

  it('shows the enterprise plan', () => {
    const user = makeUser({ plan: 'enterprise' });

    cy.intercept('GET', '/api/me', { statusCode: 200, body: user }).as('me');
    cy.visit('/profile');

    cy.wait('@me').its('response.statusCode').should('eq', 200);
    cy.get('[data-testid="profile-name"]').should('have.text', user.displayName);
    cy.get('[data-testid="plan-name"]').should('have.text', 'Enterprise');
  });
});

This example registers the intercept before navigation, which prevents the application request from racing ahead of the stub. It also checks the request completed before evaluating the rendered result. The generated value appears in the assertion, so changing the factory default does not silently invalidate a hard-coded expected name.

Add seeded variation without creating flaky mysteries

Unseeded randomness makes a failure hard to replay. Purely fixed data can miss encoding, length, and ordering defects. A seeded pseudo-random generator provides variation and replayability. The implementation below is small enough to audit and suitable for test data, not cryptography.

// cypress/support/factories/seeded.ts
export type RandomSource = {
  integer(min: number, max: number): number;
  pick<T>(values: readonly T[]): T;
};

export function seededRandom(seed: number): RandomSource {
  let state = seed >>> 0;

  function next(): number {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  }

  return {
    integer(min, max) {
      return Math.floor(next() * (max - min + 1)) + min;
    },
    pick<T>(values: readonly T[]): T {
      return values[this.integer(0, values.length - 1)];
    },
  };
}

Feed the source into a scenario factory rather than reading global randomness inside the factory:

// cypress/support/factories/catalog.ts
import { seededRandom } from './seeded';

export type CatalogItem = {
  sku: string;
  name: string;
  priceCents: number;
  stock: number;
  tags: string[];
};

export function makeCatalog(seed: number, count: number): CatalogItem[] {
  const random = seededRandom(seed);
  const adjectives = ['Compact', 'Durable', 'Quiet', 'Portable'] as const;
  const nouns = ['Lamp', 'Keyboard', 'Stand', 'Speaker'] as const;

  return Array.from({ length: count }, (_, index) => ({
    sku: 'QA-' + seed + '-' + String(index + 1).padStart(3, '0'),
    name: random.pick(adjectives) + ' ' + random.pick(nouns),
    priceCents: random.integer(500, 25000),
    stock: random.integer(0, 20),
    tags: index % 2 === 0 ? ['featured'] : [],
  }));
}

Choose the seed from a declared environment value with a stable fallback. Cypress exposes configured environment values through Cypress.env(). Convert and validate before generation so an invalid CI variable fails loudly.

// cypress/e2e/catalog.cy.ts
import { makeCatalog } from '../support/factories/catalog';

function testSeed(): number {
  const raw = Cypress.env('dataSeed');
  const seed = raw === undefined ? 8082026 : Number(raw);
  if (!Number.isInteger(seed)) {
    throw new Error('dataSeed must be an integer, received: ' + String(raw));
  }
  return seed;
}

it('sorts generated products from low to high price', () => {
  const seed = testSeed();
  const products = makeCatalog(seed, 12);

  cy.log('replay dataSeed=' + seed);
  cy.intercept('GET', '/api/products', { statusCode: 200, body: products }).as('products');
  cy.visit('/catalog');
  cy.wait('@products');
  cy.contains('button', 'Price: low to high').click();

  cy.get('[data-testid="price-cents"]').then(($prices) => {
    const actual = [...$prices].map((node) => Number(node.textContent));
    const expected = [...actual].sort((left, right) => left - right);
    expect(actual, 'prices for seed ' + seed).to.deep.equal(expected);
  });
});

The fallback seed is illustrative. A CI matrix can deliberately supply several known seeds, but each job should print its seed. Do not generate a seed from Date.now() unless the generated value is captured in failure artifacts and can be supplied on rerun.

Deliver generated responses through intercepts

An intercept can return a fixed generated object or calculate a response for each matching request. Use a fixed object when the state should remain stable. Use a route handler when the request body or call count controls the response.

Intercept styleResponse timingUse it forAssertion to retain
Static { body }Same body for every matchStable detail pagesStatus and key rendered fields
Handler with req.reply()Computed per requestSearch, pagination, state transitionsRequest inputs and response sequence
{ fixture: 'file' }Cached fixture contentImmutable canonical examplesFixture contract
Middleware or pass-throughReal server may answerHeader changes, selective stubbingWhich calls were real versus stubbed

Here is a complete pagination scenario. The handler derives the slice from the request URL, and the assertion proves both pages were requested.

// cypress/e2e/generated-pagination.cy.ts
import { makeCatalog } from '../support/factories/catalog';

it('requests the next generated catalog page', () => {
  const products = makeCatalog(4102, 7);

  cy.intercept('GET', '/api/products*', (req) => {
    const url = new URL(req.url);
    const page = Number(url.searchParams.get('page') ?? '1');
    const pageSize = 3;
    const start = (page - 1) * pageSize;

    req.reply({
      statusCode: 200,
      body: {
        items: products.slice(start, start + pageSize),
        total: products.length,
      },
    });
  }).as('catalogPage');

  cy.visit('/catalog');
  cy.wait('@catalogPage').its('request.url').should('include', 'page=1');
  cy.contains('button', 'Next').click();
  cy.wait('@catalogPage').its('request.url').should('include', 'page=2');
  cy.get('[data-testid="product-card"]').should('have.length', 3);
});

This handler does not need a Cypress command inside it. The products exist before the handler is registered, and every match uses the same controlled collection. That removes command-queue timing ambiguity.

For state transitions, keep state local to the test. A closure can return processing once and complete next. Do not store call counters at module scope because spec retries and later tests can inherit the mutated value.

Create persistent records through an API

An intercept proves the browser handles a response, but it does not prove the backend stored or served that record. When persistence is part of the journey, generate the request and create it through a supported API before visiting.

// cypress/e2e/account-settings.cy.ts
type CreateAccountResponse = {
  id: string;
  email: string;
};

function uniqueEmail(label: string): string {
  const runId = String(Cypress.env('runId') ?? 'local');
  return label + '+' + runId + '@example.test';
}

it('updates a generated account locale', () => {
  const email = uniqueEmail('locale-test');

  cy.request<CreateAccountResponse>('POST', '/test-support/accounts', {
    email,
    locale: 'en',
    plan: 'team',
  }).then(({ body }) => {
    cy.request('POST', '/test-support/login', { accountId: body.id });
    cy.visit('/settings');
    cy.get('[data-testid="locale-select"]').select('fr');
    cy.contains('button', 'Save').click();
    cy.contains('[role="status"]', 'Settings saved').should('be.visible');

    cy.request('/api/me').its('body.locale').should('eq', 'fr');
  });
});

The endpoint name is an example of an application-owned test-support route, not a Cypress feature. In a real system, protect such routes from production exposure. The test creates data through HTTP, authenticates without driving a login form, exercises the settings UI, and verifies the persisted state through HTTP. That is a clean split of setup, action, and verification.

Uniqueness should describe ownership. A run identifier, worker identifier, and scenario label are more useful than a random UUID because operations teams can trace stale data. If parallel jobs share one environment, include all three in server-visible keys. In shell configuration, delimit variables explicitly, such as DATA_NAMESPACE="${CI_PIPELINE_ID}_${CI_NODE_INDEX}", so the shell does not consume an unexpectedly long variable name.

Generate files only when the file is the behavior

Writing generated JSON to the fixtures directory is often unnecessary. Pass an object to cy.intercept() instead. A file is appropriate when the application accepts a file upload, watches a path, or exposes an import workflow.

The following CSV generator avoids commas and quotes in field values by constraining its inputs. That makes the produced CSV valid for the declared data set.

// cypress/e2e/customer-import.cy.ts
type CsvCustomer = {
  email: string;
  tier: 'basic' | 'pro';
};

function customerCsv(rows: CsvCustomer[]): string {
  const lines = rows.map((row) => row.email + ',' + row.tier);
  return ['email,tier', ...lines].join('\n') + '\n';
}

it('imports generated customer rows', () => {
  const path = 'cypress/downloads/generated-customers.csv';
  const rows: CsvCustomer[] = [
    { email: 'ada@example.test', tier: 'pro' },
    { email: 'lin@example.test', tier: 'basic' },
  ];

  cy.writeFile(path, customerCsv(rows));
  cy.visit('/customers/import');
  cy.get('input[type="file"]').selectFile(path);
  cy.contains('button', 'Import').click();
  cy.contains('[role="status"]', 'Imported 2 customers').should('be.visible');
});

If a test updates a file and needs to observe the new contents, use cy.readFile(). Cypress documentation states that cy.fixture() loads fixture files once and yields content that is not updated when the disk file changes. That caching is intentional because fixtures are assumed to be fixed. Rewriting a file and then calling cy.fixture() again is a realistic failure mode: the command can yield the originally cached object, making the test appear to ignore its write.

Diagnosis is straightforward. Log the result of cy.readFile() and compare it with the value yielded by the earlier fixture command. If disk reflects the change but the intercept still returns old content, replace the fixture reference with an object response or read the mutable file at the point it is needed. The official behavior is documented at https://docs.cypress.io/api/commands/fixture.

Put privileged generation behind a narrow task

Use cy.task() when setup must run in Node, for example to seed a local database using an existing repository function. Tasks must end with a serializable value, and the browser test should receive only the identity it needs. Keep SQL and connection details out of spec files.

// cypress.config.ts
import { defineConfig } from 'cypress';

type SeedOrder = {
  reference: string;
  state: 'paid' | 'refunded';
};

async function insertOrder(order: SeedOrder): Promise<{ id: string }> {
  // Replace this application adapter with the repository's real test database helper.
  return { id: 'order-' + order.reference };
}

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on('task', {
        async seedOrder(order: SeedOrder) {
          return insertOrder(order);
        },
      });
    },
  },
});
// cypress/e2e/refund.cy.ts
it('renders a generated refunded order', () => {
  const reference = 'refund-' + String(Cypress.env('runId') ?? 'local');

  cy.task<{ id: string }>('seedOrder', {
    reference,
    state: 'refunded',
  }).then(({ id }) => {
    cy.visit('/orders/' + id);
  });

  cy.get('[data-testid="order-reference"]').should('have.text', reference);
  cy.get('[data-testid="order-state"]').should('have.text', 'Refunded');
});

The stubbed insertOrder body makes the configuration example executable, but a real suite should import its established adapter. Do not create a general “run SQL” task that accepts arbitrary query text from the browser process. A named task such as seedOrder has a reviewable input contract and can apply tenant, ownership, and cleanup rules centrally.

Validate generated data at the seam

Factories can drift away from the production API. TypeScript catches local type mismatches, but it does not prove a server response or JSON file matches a runtime contract. Assert the properties that establish the scenario at the boundary, then assert user-visible behavior.

Validation layerQuestion answeredExample failure caught
Type checkingDoes test code use the declared shape?Misspelled displayName property
Factory unit testAre defaults and overrides composed correctly?Nested preferences erased
API setup assertionDid the server accept and normalize the record?Server downgrades unsupported plan
Intercept request assertionDid the UI send the generated identity?Form submits a stale user ID
UI assertionCan the user observe the intended state?Suspended badge is hidden

Do not assert every generated field in every end-to-end test. That duplicates schema tests and makes harmless fixture changes expensive. Assert fields that drive the scenario, fields the user sees, and identities needed to connect actions to records.

Factory unit tests can run in the project's existing unit runner. A concise contract test should check fresh references, stable output for a seed, and override behavior. It should not snapshot huge generated objects because snapshots conceal which rule matters.

Diagnose the failures dynamic data commonly creates

Generated tests tend to fail in recognizable ways. First identify the boundary, then inspect ownership and timing.

SymptomLikely causeDiagnostic moveDurable correction
Old response after rewriting JSONcy.fixture() returned cached contentCompare with cy.readFile()Respond with an object or read mutable content explicitly
Duplicate-key error in CI onlyParallel workers share an identity namespacePrint generated IDs and worker metadataInclude run and worker ownership in unique keys
Test cannot reproduce locallySeed came from ambient randomnessCapture inputs from failure artifactsAccept and log a replay seed
Later test sees mutated nested fieldsFactory reused an object referenceCompare object identity between callsAllocate nested arrays and objects per call
Intercept misses the first requestRegistered after cy.visit()Inspect Cypress network command orderRegister intercept before navigation or action
Generated record exists but UI cannot find itBackend indexing or eventual consistencyVerify API response and poll a supported readiness endpointCreate synchronously or expose readiness explicitly

A common incorrect reaction is adding cy.wait(1000). That may hide an indexing delay on one machine while making the suite slower everywhere. Wait on an observable condition: an aliased network call, a job-status endpoint, or the eventual UI state with Cypress retryability. The setup API should ideally return only when the record is ready for the tested path.

What people get wrong about dynamic fixtures

The most damaging misconception is that “dynamic” means “random.” Useful generation is scenario-controlled. If every field changes, failures carry noise and edge cases appear by accident. Keep irrelevant fields stable and vary one deliberate dimension at a time. A separate exploratory job can execute a small seed matrix, but the ordinary regression suite should remain explainable.

Another mistake is treating fixture generation as a substitute for production-like setup. An object supplied by cy.intercept() cannot validate database constraints, authentication, serialization, caching, or service integration. Conversely, using the real database for a component rendering branch adds time and cleanup with no gain. Match the boundary to the claim in the test name.

Finally, avoid a universal “fixture builder” with dozens of optional switches. It becomes a second application whose invalid combinations are hard to see. Domain-specific factories such as makeSuspendedUser, makePaidOrder, and makeCatalogPage can call smaller primitives while preserving business meaning.

Turn the pattern into an agent-friendly workflow

AI coding agents can generate many tests quickly, so guardrails matter more than typing speed. Give the agent the real response type, one accepted seed pattern, ownership rules for records, and a cleanup API. Ask it to state which boundary each test uses and what behavior that boundary does not cover. Review generated tests for accidental wall-clock values, module-level mutation, arbitrary waits, and assertions that merely repeat factory inputs.

A practical pull-request checklist is:

  1. The factory returns a valid object without overrides.
  2. Nested mutable values are fresh on every call.
  3. Variation has a captured, replayable seed.
  4. Intercepts are registered before the triggering action.
  5. Persistent records carry run and worker ownership.
  6. Cleanup is automatic or the environment has a documented expiry policy.
  7. The assertion proves an externally meaningful result.
  8. Mutable files use cy.readFile(), not a previously cached fixture value.

Ready-made QA skills can be installed from qaskills.sh with the qaskills CLI when a team wants agents to follow a consistent testing workflow. The value comes from pairing those instructions with project-specific types and setup endpoints, not from generating more data than a scenario needs.

Frequently Asked Questions

Should Cypress fixtures dynamic generation replace every JSON fixture?

No. Keep static JSON for canonical, stable examples that reviewers benefit from seeing as files, such as a signed webhook sample with secrets removed or a compact contract payload. Generate data when tests need uniqueness, relationships, boundary values, or clear scenario overrides. A hybrid is often strongest: load a stable baseline once, clone it, apply explicit changes, and pass the resulting object to an intercept. Do not rewrite that fixture and expect cy.fixture() to reload it during the same run.

How should a team reproduce a failure caused by generated data?

Make the seed an explicit input, validate it, and print it with the failing scenario. Also record any run namespace, worker index, and factory version or commit. A developer should be able to rerun the same spec with the captured seed and obtain the same object sequence. If external services enrich data, capture their accepted response or use a controlled test-support endpoint. A seed alone cannot reproduce behavior when ambient time, shared database state, or nondeterministic services still influence the result.

Is cy.task better than cy.request for seeding test data?

Neither is universally better. Prefer cy.request() when the application exposes a supported setup API and the behavior should pass through normal server validation. Use cy.task() when setup must stay in the Node process, such as calling an existing database adapter or generating a binary file. Keep tasks narrow and typed. Do not expose unrestricted database execution. Whichever boundary you choose, return a small serializable identity, label data with test ownership, and provide deterministic cleanup.

How much generated variation belongs in a regression suite?

Vary enough to exercise meaningful equivalence classes, not every field on every run. Keep a stable default suite for fast diagnosis, then add a small, declared seed matrix for encoding, length, sorting, and boundary risks. Each matrix entry should have a reason and remain replayable. Large random campaigns belong in a separate property-based or exploratory job with failure shrinking and artifact capture. The regression signal stays useful when a failing case tells the engineer which rule was challenged and how to reproduce it.