Why is this an issue?

When several tests have the same structure and differ only by a few hardcoded values, they are harder to maintain as separate tests. A table-driven test makes the common behavior explicit and keeps the varying data in one place.

The right balance needs to be found. There is no point in grouping tests when the parameterized version is more complex than the original tests.

For JavaScript and TypeScript, this rule targets Jest-, Vitest-, and Playwright-compatible test and it APIs. It raises an issue when at least three tests in the same scope can be refactored as one parameterized test.

To avoid recommending a table-driven test that is harder to read than the original tests, this rule uses a conservative heuristic. It only reports tests with the same statement structure, where the differences are literal values of the same kind, for example strings with strings or numbers with numbers. It reports only when the common structure still contains duplicated statements after the varying literals are parameterized, and when no more than three literals need to become parameters.

Code examples

Noncompliant code example

import { test, expect } from "vitest";

test("describes Europa", () => { // Noncompliant
  const moon = describeMoon("europa");
  expect(moon.kind).toBe("moon");
  expect(moon.planet).toBe("Jupiter");
  expect(moon.label).toBe("Europa");
});

test("describes Io", () => {
  const moon = describeMoon("io");
  expect(moon.kind).toBe("moon");
  expect(moon.planet).toBe("Jupiter");
  expect(moon.label).toBe("Io");
});

test("describes Ganymede", () => {
  const moon = describeMoon("ganymede");
  expect(moon.kind).toBe("moon");
  expect(moon.planet).toBe("Jupiter");
  expect(moon.label).toBe("Ganymede");
});

Compliant solution

import { test, expect } from "vitest";

test.each([
  ["europa", "Europa"],
  ["io", "Io"],
  ["ganymede", "Ganymede"],
])("describes %s", (slug, label) => {
  const moon = describeMoon(slug);
  expect(moon.kind).toBe("moon");
  expect(moon.planet).toBe("Jupiter");
  expect(moon.label).toBe(label);
});

Noncompliant code example

import { it, expect } from "@jest/globals";

it("rejects a missing user id", () => { // Noncompliant
  const response = validateUser({ id: null });
  expect(response.valid).toBe(false);
  expect(response.error).toBe("id is required");
});

it("rejects a negative user id", () => {
  const response = validateUser({ id: -1 });
  expect(response.valid).toBe(false);
  expect(response.error).toBe("id must be positive");
});

it("rejects a non-numeric user id", () => {
  const response = validateUser({ id: "abc" });
  expect(response.valid).toBe(false);
  expect(response.error).toBe("id must be a number");
});

Compliant solution

import { it, expect } from "@jest/globals";

it.each([
  [null, "id is required"],
  [-1, "id must be positive"],
  ["abc", "id must be a number"],
])("rejects invalid user id %p", (id, error) => {
  const response = validateUser({ id });
  expect(response.valid).toBe(false);
  expect(response.error).toBe(error);
});

Noncompliant code example

import { test, expect } from "@playwright/test";

test("opens the planets page", async ({ page }) => { // Noncompliant
  await page.goto("/planets");
  await page.waitForLoadState("networkidle");
  await expect(page.getByRole("heading", { name: "Planets" })).toBeVisible();
});

test("opens the moons page", async ({ page }) => {
  await page.goto("/moons");
  await page.waitForLoadState("networkidle");
  await expect(page.getByRole("heading", { name: "Moons" })).toBeVisible();
});

test("opens the comets page", async ({ page }) => {
  await page.goto("/comets");
  await page.waitForLoadState("networkidle");
  await expect(page.getByRole("heading", { name: "Comets" })).toBeVisible();
});

Compliant solution

import { test, expect } from "@playwright/test";

[
  ["/planets", "Planets"],
  ["/moons", "Moons"],
  ["/comets", "Comets"],
].forEach(([path, heading]) => {
  test(`opens the ${heading} page`, async ({ page }) => {
    await page.goto(path);
    await page.waitForLoadState("networkidle");
    await expect(page.getByRole("heading", { name: heading })).toBeVisible();
  });
});

Resources

Documentation