Data-Driven Testing with Parameterisation

8 min read

The same login form works for an admin, a tester, and a viewer. The same search input handles "laptop", "headphones", and "wireless mouse". The same email field rejects empty strings, malformed addresses, and 256-character bombs. Writing one test per case is tedious; copy-pasting the body and changing one literal each time is a maintenance nightmare. Data-driven testing is the JavaScript-idiomatic answer: define the rows once as data, generate the tests at load time. This lesson covers the pattern, when to use it, and how to keep the generated tests readable in the runner output.

The basic pattern — forEach over it

JavaScript's forEach loop, called outside any it block, generates one test per row of data:

interface LoginCase {
  email: string;
  password: string;
  expectedPath: string;
}
 
const cases: LoginCase[] = [
  { email: "admin@test.com",  password: "AdminPass1!",  expectedPath: "/admin" },
  { email: "user@test.com",   password: "UserPass1!",   expectedPath: "/dashboard" },
  { email: "viewer@test.com", password: "ViewerPass1!", expectedPath: "/readonly" },
];
 
describe("Login redirects per role", () => {
  cases.forEach(({ email, password, expectedPath }) => {
    it(`logs in as ${email} and lands on ${expectedPath}`, () => {
      cy.visit("/login");
      cy.get("[data-testid='email']").type(email);
      cy.get("[data-testid='password']").type(password);
      cy.get("[data-testid='submit']").click();
      cy.url().should("include", expectedPath);
    });
  });
});

Three things happen here:

  • The forEach runs at spec-load time — Mocha's it calls register three independent tests.
  • Each test gets its own descriptive name interpolated from the data: "logs in as admin@test.com and lands on /admin", and so on.
  • Each test is fully independent — its own pass/fail, its own retry, its own screenshot. If one fails, the others still run.

The runner output shows three named test entries. Failure messages say exactly which row broke. This is the baseline data-driven pattern — once you've seen it, you'll reach for it constantly.

What you get vs writing three it blocks

Each row produces one runner entry. Add a fourth role tomorrow — append one row to the array, re-run, four tests. Drop a role — remove a row, three tests. The test body itself never changes.

Typing the test data

The TypeScript interface is small but pays off the moment data drifts:

interface LoginCase {
  email: string;
  password: string;
  expectedPath: string;
  description?: string;   // optional — overrides the auto-generated name if you want
}

A typo in any field — expectedpath instead of expectedPath, a missing password — fails at compile time, not in the runner. Across hundreds of rows that's the difference between fixing a typo in the editor versus debugging a half-run suite.

Use description? when the auto-generated name is awkward:

const cases: LoginCase[] = [
  {
    email: "alice@test.com",
    password: "AlicePass1!",
    expectedPath: "/admin",
    description: "admin user redirected to admin home",
  },
  // ...
];
 
cases.forEach((c) => {
  it(c.description ?? `logs in as ${c.email}`, () => {
    // ...
  });
});

Stick with auto-generated names when they read well. Override with description only when interpolation gets clunky.

Negative-test data

The pattern shines for negative tests — every input-validation case the spec would otherwise need to copy:

const invalidEmails = [
  { input: "",                              error: "Email is required" },
  { input: "not-an-email",                  error: "Invalid email format" },
  { input: "alice@",                        error: "Invalid email format" },
  { input: "@test.com",                     error: "Invalid email format" },
  { input: "a".repeat(256) + "@test.com",   error: "Email too long" },
  { input: "<script>alert(1)</script>",     error: "Invalid characters" },
];
 
describe("Email validation", () => {
  invalidEmails.forEach(({ input, error }) => {
    it(`rejects "${input.slice(0, 30)}" with "${error}"`, () => {
      cy.visit("/register");
      cy.get("[data-testid='email']").type(input);
      cy.get("[data-testid='submit']").click();
      cy.get("[data-testid='email-error']").should("contain", error);
    });
  });
});

Six tests, six clean failures if any branch of validation regresses. Slicing the input in the test name keeps long bombs readable. Six rows is roughly the minimum size where the data-driven pattern earns its keep — three or fewer cases are usually clearer as inline tests.

Loading test data from a fixture

Larger datasets belong in fixtures. The catch: cy.fixture is async, so you can't use it inside a top-level forEach. Two patterns work:

Pattern 1: load synchronously at spec-load time with a Node-side require wrapped in a before block — fine for static data:

import casesData from "../fixtures/login-cases.json";
const cases: LoginCase[] = casesData;
 
describe("Login scenarios", () => {
  cases.forEach((c) => {
    it(`logs in as ${c.email}`, () => { /* ... */ });
  });
});

The direct ES import works because .json files can be imported synchronously by the bundler Cypress uses. The downside: changes to the fixture require a spec reload.

Pattern 2: load inside before and run a single test that iterates internally — used when the data must be loaded asynchronously:

describe("Login scenarios from fixture", () => {
  let cases: LoginCase[];
 
  before(() => {
    cy.fixture<LoginCase[]>("login-cases.json").then((data) => {
      cases = data;
    });
  });
 
  it("runs all login scenarios", () => {
    cases.forEach((c) => {
      cy.visit("/login");
      cy.get("[data-testid='email']").type(c.email);
      cy.get("[data-testid='password']").type(c.password);
      cy.get("[data-testid='submit']").click();
      cy.url().should("include", c.expectedPath);
    });
  });
});

The trade-off: pattern 2 produces one test entry that runs every case. If one row fails, the whole it is red and you can't tell which case broke without reading the failure message. Most teams use pattern 1 for this reason — separate it per row gives clean reporting.

When data-driven testing is worth it

Three rules of thumb:

  • Three or more similar cases. Two cases as inline tests are clearer than two cases plus boilerplate to define an array. Five cases as inline tests are an obvious refactor target.
  • The cases differ only in data. If half of the rows need different setup or different assertions, parameterisation produces awkward conditionals. Split those out as separate specs.
  • The data is reasonably stable. Cases that change every release belong in code comments or release notes, not in a parameterised array.

Cross-browser parameterisation — running the same spec in Chrome, Firefox, Edge — is not done in test code. It's done in cypress.config.ts matrix jobs or CI workflow strategy.matrix blocks (chapter 8). Don't try to parameterise browser inside a spec.

A real product-search example

Pulling the pattern into a realistic spec — ten search terms with expected result counts:

interface SearchCase {
  term: string;
  minResults: number;
  shouldContain?: string[];
}
 
const cases: SearchCase[] = [
  { term: "laptop",         minResults: 3, shouldContain: ["MacBook", "ThinkPad"] },
  { term: "headphones",     minResults: 5, shouldContain: ["Wireless", "Over-ear"] },
  { term: "wireless mouse", minResults: 2 },
  { term: "keyboard",       minResults: 4 },
  { term: "monitor",        minResults: 3 },
  { term: "webcam",         minResults: 1 },
  { term: "usb-c cable",    minResults: 2 },
  { term: "external ssd",   minResults: 2, shouldContain: ["1TB"] },
  { term: "graphics card",  minResults: 1 },
  { term: "router",         minResults: 1 },
];
 
describe("Product search", () => {
  beforeEach(() => {
    cy.visit("/products");
  });
 
  cases.forEach((c) => {
    it(`finds ≥${c.minResults} results for "${c.term}"`, () => {
      cy.get("[data-testid='search-input']").type(c.term);
 
      cy.get("[data-testid='product-card']")
        .should("have.length.at.least", c.minResults);
 
      c.shouldContain?.forEach((needle) => {
        cy.get("[data-testid='product-grid']").should("contain", needle);
      });
    });
  });
});

Ten generated tests, each with a clear name in the runner output. Add a search term tomorrow — append one row. Remove a deprecated product line — drop the row. The test body never has to change.

⚠️ Common mistakes

  • Putting cy.fixture inside a top-level forEach. cy.fixture is async, so it returns a chainable, not a value. The forEach runs immediately and the data isn't there yet. Either import the JSON directly (synchronous) or load it in a before hook and accept that the iterating test is one it block.
  • Using a single it block to iterate when separate it blocks would do. One it("runs all 10 cases") produces one runner entry. If row 5 fails, you can't see at a glance that rows 1–4 and 6–10 passed; the whole test is red. Separate it per row is almost always the right call.
  • Parameterising tests that don't share enough body. "Test login redirects" and "test login error messages" might look related but exercise different DOM. Forcing both into one parameterised loop produces an array with confusing optional fields and a test body full of conditionals. Two parameterised describes — or just two regular tests — read better.

🎯 Practice task

Convert a copy-pasted spec into a data-driven one. 25-30 minutes.

  1. Pick any spec in your project that has 3+ similar it blocks (login by role, email validation, search terms — Sauce Demo's various users like standard_user / locked_out_user / problem_user / error_user work well).
  2. Define a typed interface for the test case shape. Fill an array with rows for each variation.
  3. Wrap the existing test body in a cases.forEach((c) => it(...)) block. Confirm the runner produces one entry per row, all named clearly.
  4. Add a negative-test array for one of your forms — empty input, malformed input, oversized input, special characters. Generate one test per row that asserts the right error message appears.
  5. Move test data to a fixturecypress/fixtures/login-cases.json. Import it via direct ES import (import data from "../fixtures/login-cases.json") and confirm the same test body still works. Type the import with the case interface.
  6. Optional descriptions — add a description?: string field. Use it for one row whose default name reads awkwardly ("rejects empty input" vs "rejects ''"). Keep the auto-generated names for the rest.
  7. Stretch: parameterise a search-results spec with 8–10 terms, asserting both the minimum count and at least one expected product per term. Note how trivial it is to add a new term tomorrow.

That ends chapter 5. You now have the four reusable surfaces — custom commands, page objects, app actions, environment config — and the data-driven loop that ties them all together. The next chapter takes the framework into the territory authentication usually breaks: login strategies, session caching, OAuth, and multi-step flows.

// tip to track lessons you complete and pick up where you left off across devices.