Home / Blog / Playwright test data

Playwright test data: where it comes from, who deletes it, and what happens in parallel

Quick answer

Playwright gives every test its own browser context and nothing else. Your database is still shared, so test data is your problem: seed it through the API before the test, give every record an identifier no other test can produce, and delete it in teardown. Where neither is possible, isolate per worker or per test — a tenant, a schema, an account.

Two different things get called test data and only one of them is this article. A table of thirty postcodes fed to one parameterised test is a loop, and a loop does not care how many workers are running. State that the application holds — a customer, an invoice, a subscription on day 400 — is the subject here, and it breaks the week somebody turns the workers up.

Why does my suite pass one test at a time and fail with four workers?

Playwright isolates the browser and stops there. Every test gets a fresh BrowserContext with its own cookies, its own local storage and its own session storage, built before the test and thrown away after it. Nothing you did in the last test is visible in this one.

The documentation states the goal more widely than that, under Make tests as isolated as possible: "Each test should be completely isolated from another test and should run independently with its own local storage, session storage, data, cookies etc." Three of the four things in that list are handled for you. Data is not.

Your database is one database. So is the queue, the search index, the file on disk and the payment sandbox with three test cards in it. Four workers are four processes talking to all of that at once, and the runner has no idea any of it exists.

It looks like this when it is nobody's fault in particular: two tests, one file, no variable shared between them, both correct on their own. The code here is TypeScript, it drives a small local API, and it was run on Playwright 1.62.1 — the patch npm install hands you for the 1.62 line.

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

const EMAIL = 'checkout@example.com';

test('a customer on the starter plan sees the upgrade prompt', async ({ page, request }) => {
  const created = await request.post('/api/customers', {
    headers: { 'x-api-key': process.env.SEED_KEY! },
    data: { email: EMAIL, name: 'Checkout Co', plan: 'starter' },
  });
  expect(created.ok()).toBeTruthy();
  const customer = await created.json();

  await page.goto(`/customers/${customer.id}`);
  await expect(page.getByTestId('plan')).toHaveText('starter');

  await request.delete(`/api/customers/${customer.id}`, {
    headers: { 'x-api-key': process.env.SEED_KEY! },
  });
});

test('a customer on the pro plan does not see it', async ({ page, request }) => {
  const created = await request.post('/api/customers', {
    headers: { 'x-api-key': process.env.SEED_KEY! },
    data: { email: EMAIL, name: 'Checkout Co', plan: 'pro' },
  });
  expect(created.ok()).toBeTruthy();
  const customer = await created.json();

  await page.goto(`/customers/${customer.id}`);
  await expect(page.getByTestId('plan')).toHaveText('pro');

  await request.delete(`/api/customers/${customer.id}`, {
    headers: { 'x-api-key': process.env.SEED_KEY! },
  });
});
Playwright 1.62.1 · TypeScript · tests/collision.spec.ts

With one worker the suite is green, because the first test deletes its customer before the second one asks for the same address.

$ npx playwright test collision --workers=1

Running 2 tests using 1 worker

  ok 1 tests\collision.spec.ts:5:5 › a customer on the starter plan sees the upgrade prompt (181ms)
  ok 2 tests\collision.spec.ts:21:5 › a customer on the pro plan does not see it (109ms)

  2 passed (2.2s)
Playwright 1.62.1 · the run

With two, they overlap, and the API refuses the second address while the first test still owns it.

$ npx playwright test collision --workers=2

Running 2 tests using 2 workers

  ok 1 tests\collision.spec.ts:21:5 › a customer on the pro plan does not see it (340ms)
  x  2 tests\collision.spec.ts:5:5 › a customer on the starter plan sees the upgrade prompt (333ms)

  1) tests\collision.spec.ts:5:5 › a customer on the starter plan sees the upgrade prompt

    Error: expect(received).toBeTruthy()

    Received: false

       8 |     data: { email: EMAIL, name: 'Checkout Co', plan: 'starter' },
       9 |   });
    > 10 |   expect(created.ok()).toBeTruthy();
         |                        ^
Playwright 1.62.1 · the run

Which of the two fails moves between runs, because it is whichever one reaches the API second: seven runs here produced six failures of the second test and one of the first. From the report it looks like flakiness.

Nothing in that message says two tests wanted the same row. It says a boolean was false. The symptoms in this category all arrive disguised as something else, so the first move is to recognise the category. Reading the stack trace again will not get you there.

Where should the data come from, and what does each option cost?

There is no Playwright documentation page about test data. docs/test-data returns a 404, and ten other playwright.dev/docs/ URLs answered on the same pass, so the route is alive and the page is simply not there. The judgement lives in two sentences under Testing with a database on the best-practices page — "If working with a database then make sure you control the data. Test against a staging environment and make sure it doesn't change." — and the mechanism lives on the parallelism page, under headings called Give each test its own backend data, Isolate test data between parallel workers and Worker index and parallel index. It is all documented, filed under a word nobody types when they are looking for it.

Three doors lead to a record being in front of a test, and choosing between them is a cost decision.

Where the data comes fromWhat it costs youYou have outgrown it when
Seeded through the API before the test An API that can create the thing, credentials for it in CI, and a rule that the seed path is never the feature under test Seeding one invoice means creating a customer, a product, a price and a tax rule, and the seed helper has quietly become a second implementation of your domain
Created through the interface, by driving the product Wall-clock time on every test, and the least stable part of the system standing in front of the part you meant to test You are doing it once per test rather than once per worker, or a setup step has started failing for reasons that have nothing to do with setup
A tenant, schema or account per worker, or per test The product has to support it, and something has to create and destroy the tenants — usually somebody who does not report to you Nothing outgrows it. Plenty of products cannot offer it

Seeding through the API is the right default for most suites. It is the fastest of the three and it fails with a status code, and the setup lives in code you own. Driving the interface is usually the wrong answer and is sometimes the only one, which is its own section below. Per-worker isolation keeps working as the suite grows and it is the one you cannot always buy.

A fourth answer is to have no data and fabricate the response instead. Intercepting the request answers what the screen does when the server says this, which is often the only way to reach an error path or a third-party failure. It cannot answer whether your system produces that response, because the fixture and the assertion agree with each other by construction. Which endpoints to mock and which to leave real takes that decision apart.

What does a seed helper that calls the API look like?

The runner ships an APIRequestContext as the built-in request fixture, so a test can create the record it needs over HTTP before it opens a page. The API-testing guide builds one in a beforeAll hook and shares it across a file. A suite wants one function it can call from anywhere.

import type { APIRequestContext } from '@playwright/test';

export type Customer = {
  id: string; email: string; name: string; plan: string; startedAt: string;
};

export async function seedCustomer(
  api: APIRequestContext,
  key: string,
  fields: Partial<Customer> = {},
): Promise<Customer> {
  const response = await api.post('/api/customers', {
    headers: { 'x-api-key': process.env.SEED_KEY! },
    data: {
      email: `customer-${key}@example.test`,
      name: `Customer ${key}`,
      plan: 'starter',
      ...fields,
    },
  });
  if (!response.ok())
    throw new Error(`seedCustomer(${key}) failed: ${response.status()} ${await response.text()}`);
  return response.json();
}
Playwright 1.62.1 · TypeScript · tests/seed/customers.ts
import { test, expect } from '@playwright/test';
import { seedCustomer } from './seed/customers';

test('a pro customer does not see the upgrade prompt', async ({ page, request }, testInfo) => {
  const customer = await seedCustomer(request, testInfo.testId, { plan: 'pro' });

  await page.goto(`/customers/${customer.id}`);
  await expect(page.getByTestId('plan')).toHaveText('pro');
});
Playwright 1.62.1 · TypeScript · passes

It throws, loudly, with the status in the message

A seed that fails silently produces a test that fails on the assertion, and the report then blames the feature. Run the same spec with the wrong credentials and the failure names itself before the browser has opened:

Error: seedCustomer(3b0c2121f96cc1cd6c6e-af8c9b6fe9027689193d) failed: 401 {"error":"seed credentials required"}

   at seed\customers.ts:22
Playwright 1.62.1 · the run

Which is also the reminder that the seed call carries credentials of its own. They are an API key or a service token, not the session the browser is using, and they belong in CI secrets rather than in a fixture file — how a test gets a signed-in session is a separate problem.

The seed path is never the feature under test

If POST /api/customers is the endpoint the test is about, seeding through it proves nothing: you have asserted that the endpoint agrees with itself. Create the record through a different route, or accept that this particular test has to build its record the slow way, through the interface.

The identifier decides whether two workers collide

A unique value can be derived from the test, derived from the worker, or drawn at random, and the three are not interchangeable. Derived from the test, with testInfo.testId, is the strongest default: it is unique per test and it is stable between runs. Two separate runs of the same spec printed the same id both times, 3b0c2121f96cc1cd6c6e-af8c9b6fe9027689193d, so a failed run can be re-run against the row that failed and the row can still be found in the database an hour later. Derived from the worker, with one of the two indices, is for something shared by every test that worker runs, and it has a section below. A random value from a library like Faker is the weakest of the three, because it is not reproducible: the re-run gets a different row, and the row you find in staging next week tells you nothing about which test made it.

A helper like this is what usually ends up inside a fixture, so the test asks for the record by name and the deletion happens without an afterEach anywhere. What a fixture is for and how to declare one covers the wrapping; keeping the seed itself as a plain imported function means the setup projects and the teardown project further down this page can call it too.

When is seeding through the UI the only door?

There is no API for the thing — the endpoint was never written because no other client needed it. Or the state is a side effect of a flow nobody has factored out: the onboarding wizard sets six flags, writes an audit row and fires a webhook, and the API call that creates the account does one of those.

Driving the product to build its own data is slow and fragile, and it is still better than a seeded row that does not match what the product would have written.

Do it once per worker rather than once per test. A wizard that takes eleven seconds costs eleven seconds a run instead of eleven seconds a spec, and the worker-scoped pattern below is the same one.

Keep the setup free of assertions about the feature under test. Assert enough to know the wizard finished — a heading, an id in the URL — and nothing about the screen you are on your way to. A setup step that fails then reads as a setup failure in the report instead of as three hundred broken features.

Write down which flows depend on it. The list tells you what to delete first on the day the API appears, and without it nobody remembers which of the slow specs were slow on purpose.

One tenant per worker: parallelIndex or workerIndex?

When every test can share a dataset, building it once per worker is cheaper than building it per test. The runner offers two indices for keying it and they behave differently in the one case that matters. TestInfo documents both: of parallelIndex, "When a worker is restarted, for example after a failure, the new worker process has the same parallelIndex." Of workerIndex, "When a worker is restarted, for example after a failure, the new worker process gets a new unique workerIndex."

Read those two together and the consequence follows without anybody needing to measure it. A run in which no worker ever restarts produces the same number of tenants either way. A run with a failure in it does not: keyed on parallelIndex, the replacement worker asks for the tenant the dead one had, so the suite uses a fixed pool no larger than the worker count. Keyed on workerIndex, the replacement asks for one nothing has ever touched, so the run creates more tenants than it has workers. How many more depends on how many workers restart, which depends on your retry settings and on which tests fail.

Both arms are cheap to watch. The fixture below claims a tenant by name and tolerates the 409 that comes back when the tenant already exists, which is what makes it a claim rather than a create.

import { test as base, request } from '@playwright/test';

export const test = base.extend<{}, { tenant: string }>({
  tenant: [async ({}, use, workerInfo) => {
    const api = await request.newContext({ baseURL: 'http://localhost:3000' });
    const slug = `acme-${workerInfo.parallelIndex}`;

    const created = await api.post('/api/tenants', {
      headers: { 'x-api-key': process.env.SEED_KEY! },
      data: { slug },
    });
    if (created.status() !== 201 && created.status() !== 409)
      throw new Error(`tenant ${slug}: ${created.status()} ${await created.text()}`);
    console.log(`${created.status() === 201 ? 'created' : 'claimed'} ${slug} ` +
      `(workerIndex=${workerInfo.workerIndex} parallelIndex=${workerInfo.parallelIndex})`);

    await use(slug);
    await api.dispose();
  }, { scope: 'worker' }],
});

export { expect } from '@playwright/test';
Playwright 1.62.1 · TypeScript · tests/tenant.ts
import { test, expect } from './tenant';

for (const n of [1, 2, 3, 4, 5, 6]) {
  test(`case ${n}`, async ({ tenant }, testInfo) => {
    if (n === 1) expect(testInfo.retry, 'fails on the first attempt').toBeGreaterThan(0);
    expect(tenant).toMatch(/^acme-\d+$/);
  });
}
Playwright 1.62.1 · TypeScript · six tests, the first fails on its first attempt

Two workers, one retry, one test that fails the first time. Keyed on parallelIndex, three workers ran and two tenants existed:

$ npx playwright test tenant --workers=2 --retries=1

created acme-0 (workerIndex=0 parallelIndex=0)
created acme-1 (workerIndex=1 parallelIndex=1)
claimed acme-0 (workerIndex=2 parallelIndex=0)

  1 flaky
  5 passed (2.4s)
Playwright 1.62.1 · the run

Change the one line that builds the slug to use workerInfo.workerIndex, run the same six tests again, and the third worker asks for a third tenant:

$ npx playwright test tenant --workers=2 --retries=1

created acme-0 (workerIndex=0 parallelIndex=0)
created acme-1 (workerIndex=1 parallelIndex=1)
created acme-2 (workerIndex=2 parallelIndex=0)

  1 flaky
  5 passed (2.5s)
Playwright 1.62.1 · the run

So: key on parallelIndex when you want a fixed pool of tenants that a run reuses, which is what you want against a shared environment where somebody has to clean up afterwards. Key on workerIndex when every worker process genuinely needs a tenant nothing has ever written to, and accept that a bad night creates more of them than you planned for.

The warning that comes with either choice: a per-worker tenant is shared by every test that worker runs. Whatever one test leaves in it is the next test's starting state, and which tests land in which worker changes when you change --workers or add a file.

Per-test isolation is stronger and costs more. The runner has two affordances for it. testInfo.testId is the stable identifier above. testInfo.outputPath() hands back a path inside the test's own output directory, which the documentation says is safe to write to while other tests run in parallel; for a file a test produces, that is the whole answer. For a row in a database, you get the naming convention and you still own the rest.

Who deletes it, and what happens when the test fails?

Per-test teardown is the right default and it belongs in a fixture: the code after await use() runs when the test finishes, under the ordering rules for setup and teardown. The question nobody asks until staging is full is when that code does not run.

The probe below seeds a record in a fixture and deletes it after use(), then ends its tests badly on purpose. The server stays up afterwards so the survivors can be counted.

import { test as base, expect } from '@playwright/test';
import { seedCustomer, type Customer } from './seed/customers';

const test = base.extend<{ customer: Customer }>({
  customer: async ({ request }, use, testInfo) => {
    const customer = await seedCustomer(request, testInfo.testId);
    console.log(`seeded ${customer.id} for "${testInfo.title}"`);

    await use(customer);

    await request.delete(`/api/customers/${customer.id}`, {
      headers: { 'x-api-key': process.env.SEED_KEY! },
    });
    console.log(`teardown ran for "${testInfo.title}"`);
  },
});

test('fails an assertion', async ({ customer }) => {
  expect(customer.plan, 'deliberate failure').toBe('enterprise');
});

test('runs out of time', async ({ customer, page }) => {
  test.setTimeout(2000);
  await page.goto(`/customers/${customer.id}`);
  await expect(page.getByTestId('plan')).toHaveText('enterprise');
});

test('loses its worker process', async ({ customer }) => {
  expect(customer.plan).toBe('starter');
  process.exit(1);
});
Playwright 1.62.1 · TypeScript · tests/leak.spec.ts
$ curl -s localhost:3000/api/customers
[]

$ npx playwright test leak --workers=1 --retries=0

seeded c1 for "fails an assertion"
teardown ran for "fails an assertion"
seeded c2 for "runs out of time"
teardown ran for "runs out of time"
seeded c3 for "loses its worker process"

    Test timeout of 2000ms exceeded.
    Error: expect(locator).toHaveText(expected) failed
    Error: worker process exited unexpectedly (code=1, signal=null)

  3 failed

$ curl -s localhost:3000/api/customers
[{"id":"c3","email":"customer-903210ec32f953d9c779-2894d48c4996b5f5edd2@example.test", ...}]
Playwright 1.62.1 · the run

A failed assertion tears down. So does a test that runs out of time. A worker that dies takes its pending teardown with it, and the record it seeded is still there when the run is over. That is one leaked row from one killed process on a laptop; the same shape in CI is an out-of-memory kill or a container that lost its node.

A cancelled run is the fourth case and it does not behave like the third. Probed on 1.62.1 with a fixture that logs both halves: the interrupted test is reported as interrupted rather than failed, and its teardown ran. The teardown project did not — Playwright reported it as 1 did not run — and the same config finished that project normally when nothing interrupted the run. So cancelling is gentle where a killed worker is not, and it costs you the run-level sweep while leaving every per-test teardown intact.

One qualification on that, because it changes what the result is evidence of. The machine had no console to press Ctrl-C in, so the probe called the runner's own handler — process.on('SIGINT', ...), the only signal handler the 1.62.1 runner registers — instead of sending a signal through the operating system. It shows what the cancellation path does. It does not show that a cancelled CI job reaches that path. Two things do point that way: the runner registers a SIGINT handler and no SIGTERM handler at all, and the worker processes register empty handlers for both, which is what keeps a signal to the whole process group from killing the workers before anything can be torn down.

The run-level answer is a teardown project. A setup project can carry a teardown property naming another project, and that project runs after everything depending on the setup has finished. It is a different mechanism from a fixture's teardown and it catches different things.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './proj',
  reporter: 'list',
  use: { baseURL: 'http://localhost:3000' },
  projects: [
    { name: 'seed', testMatch: /global\.setup\.ts/, teardown: 'sweep' },
    { name: 'sweep', testMatch: /global\.teardown\.ts/ },
    { name: 'e2e', testMatch: /.*\.spec\.ts/, dependencies: ['seed'] },
  ],
});
Playwright 1.62.1 · TypeScript · playwright.config.ts
import { test as teardown } from '@playwright/test';

teardown('remove every record this run left behind', async ({ request }) => {
  const leftovers: { id: string; email: string }[] =
    await (await request.get('/api/customers')).json();

  for (const customer of leftovers)
    await request.delete(`/api/customers/${customer.id}`, {
      headers: { 'x-api-key': process.env.SEED_KEY! },
    });

  console.log(`swept ${leftovers.length} records`);
});
Playwright 1.62.1 · TypeScript · proj/global.teardown.ts

Neither file does anything without the other. Run them over the wreckage of the last experiment, with one test failing on an assertion and one killing its worker, and the sweep still runs and still collects the row the fixture could not:

$ npx playwright test

  ok 1 [seed] › proj\global.setup.ts:3:6 › create the shared reference data (41ms)
  ok 2 [e2e] › proj\app.spec.ts:4:5 › a seeded customer renders (168ms)
  x  3 [e2e] › proj\app.spec.ts:10:5 › this one fails on purpose (5.1s)
  x  4 [e2e] › proj\app.spec.ts:16:5 › this one kills the worker (0ms)
swept 5 records
  ok 5 [sweep] › proj\global.teardown.ts:3:9 › remove every record this run left behind (64ms)

  2 failed
  3 passed (9.1s)
Playwright 1.62.1 · the run

This sweep can be lost two ways. --no-deps skips dependencies and teardowns, so a developer running one project directly gets none. And a cancelled run does not reach it either, for the reason given under Who deletes it, and what happens when the test fails? — a teardown project is a project, and a run that stops stops before it, even though the per-test teardowns in that same run did fire. Whether either matters is the same question as the next one.

Cleaning up and never sharing are alternatives, not a sequence

Which of those two you need depends on something outside the suite. If every record carries an identifier no other run can produce, and the environment is rebuilt on a schedule, teardown is tidiness: leftovers are inert and something else removes them. If the environment is permanent and shared, teardown is load-bearing, and the run above is the proof that it will be missed sometimes.

A permanent environment with no sweeper outside the suite accumulates. How fast depends on your suite and your failure rate, and neither of those is a number anyone can hand you — the useful version is to count the rows your own suite leaves after a week, because that number is an argument.

What about dates, ordering and the values the product mints?

Some of the state a test depends on is never seeded at all. The product generates it, and it fails on a different schedule: on the last three days of a month, on the day the year rolls, or on the run where two records happened to be created in the same second.

Dates belong in the seed, not in the assertion

A subscription that is 400 days old is data. Seed it as data, with the start date computed backwards from the moment of the run, and the test says the same thing next February that it says today:

import { test, expect } from '@playwright/test';
import { seedCustomer } from './seed/customers';

const daysAgo = (n: number) => new Date(Date.now() - n * 86400000).toISOString();

test('a subscription on day 400 shows the renewal notice', async ({ page, request }, testInfo) => {
  const customer = await seedCustomer(request, testInfo.testId, { startedAt: daysAgo(399) });

  await page.goto(`/customers/${customer.id}`);
  await expect(page.getByTestId('day')).toHaveText('Day 400 of the subscription');
});
Playwright 1.62.1 · TypeScript · passes

Hard-code startedAt as a calendar date instead and the test is a bomb with a date on it: correct in review, correct in CI that afternoon, wrong every day after. Where the browser computes the value and the record never holds it, the lever is the Clock API, and pinning the browser's locale and time zone is a separate control with its own catch — the environment half of flaky failures covers both.

Ordering ties are a data problem

A list sorted "most recent first" is decided by whatever the store returns when two timestamps are equal, and plenty of schemas keep timestamps to the second. Two invoices created inside the same second, asserted on by position:

first=INV-4101 2026-09-01T07:55:11.000Z second=INV-4102 2026-09-01T07:55:11.000Z

    Error: expect(locator).toContainText(expected) failed

    Locator: getByTestId('invoice').first()
    Expected substring: "INV-4102"
    Received string:    "INV-4101 · 10.00"
Playwright 1.62.1 · the run

Fix it in the data. Make the thing you are looking for identifiable, then find it by what it is:

import { test, expect } from '@playwright/test';
import { seedCustomer } from './seed/customers';

test('the invoice this test created appears in the list', async ({ page, request }, testInfo) => {
  const customer = await seedCustomer(request, testInfo.testId);

  const response = await request.post('/api/invoices', {
    headers: { 'x-api-key': process.env.SEED_KEY! },
    data: { customerId: customer.id, amount: '20.00' },
  });
  const invoice = await response.json();
  expect(invoice.number).toMatch(/^INV-\d+$/);

  await page.goto('/invoices');
  await expect(page.getByTestId('invoice').filter({ hasText: invoice.number })).toBeVisible();
});
Playwright 1.62.1 · TypeScript · passes

Never hard-code a value the product minted

Invoice numbers, order references, slugs and anything else with a sequence behind it belong to the environment. INV-4102 is correct exactly once. Capture the value from the response, as above, or assert its shape with expect(invoice.number).toMatch(/^INV-\d+$/) and leave the counter alone.

When this bites you

The suite that only passes on the second run

Somebody debugged a spec by creating the record it needed, by hand, six months ago. The spec has been green ever since, on that environment. On a fresh database it fails immediately, and the first person to find out is whoever tries to run the suite in a new region or on a pull-request environment. The test never created anything; it only ever read.

The shared account whose password rotated

Four fixed accounts in fixtures/users.json, added in March, and one of them expires. Every spec that uses it fails in the same run and the report says "timeout waiting for selector" three hundred times, because the failure happens on a login page nobody asserted on. The tell is that the failures arrived together and the application did not change.

The per-worker tenant one test wrote to

A test archives the tenant's only project. Every later test in that worker sees an empty dashboard, so the test that reports the failure is not the test that caused it. The pairing moves when you change --workers, when you add a file, and when the sharding splits differently, so it gets filed as flakiness while the test that caused it keeps running.

The seed that outran the application

POST /api/orders returned 201 and the search index has not caught up, or the queue has not drained, or a cache still holds the old list. The test navigates and finds nothing. The instinct is to add a fixed wait, which converts a race into a slower race — what to use instead of a sleep is the article for that. The record exists and the read path has not seen it yet, so the assertion has to be on the read path.

When the answer is not a Playwright pattern

Everything above assumes you can create data and delete it, and that whatever you do to the environment is yours to do. Plenty of teams are not in that position: four teams to one staging database, a restore that takes a working day and a ticket, rows called Test Customer that nobody will claim, and no runs on Thursdays because somebody is demoing.

No fixture fixes that. Neither does parallelIndex, or a teardown project, or a naming convention nobody outside your team follows. A suite that tries to work around a shared permanent environment becomes an elaborate apology for it, and the workarounds cost more every quarter. The answer is an environment that can be created and destroyed — per branch, per run, or at worst rebuilt nightly.

That is usually not the test engineer's decision, which is where most of these conversations stop. Cost the current arrangement: how many runs a week go red for a reason that is not the product, how many hours go into re-running them, how long the last restore took. Somebody who can authorise a disposable environment needs a number, and you are the only person who can produce it.

Where the repository is the problem and the environment is fine — no fixtures, no seed layer, and four specs that only pass in the order they were written — that is Playwright framework development: the structure, the fixtures, the login, the test data and the CI job, built in your repository with reference tests that show each pattern.

Questions

Why does one Playwright test pass on its own and fail when the suite runs?

Because Playwright isolates the browser and nothing else. Each test gets its own browser context, with its own cookies and storage, and the runner knows nothing about your database, your queue or your search index. Run one test at a time and two specs that use the same record take turns; run four workers and they collide, usually as a uniqueness error or an assertion on a value another test just changed.

Should I create test data through the API or through the UI?

Through the API by default. It is faster, it fails with a status code instead of a broken assertion, and the setup lives in code you own. Two conditions make the interface the only door: there is no endpoint that creates the thing, or the state is a side effect of a flow nobody has factored out, such as an onboarding wizard that also writes an audit row and fires a webhook. When you do drive the interface, do it once per worker rather than once per test.

How do I stop two Playwright workers using the same test data?

Give every record an identifier no other test can produce, derived from testInfo.testId, which is unique per test and stable between runs. Where tests can share one dataset, build it once per worker and key it on parallelIndex if you want a fixed pool, because a restarted worker keeps the same parallelIndex; key it on workerIndex only when each worker process needs something untouched, because a restarted worker gets a new workerIndex and a run with failures in it then creates more than it has workers.

What happens to seeded data when a Playwright test fails?

A fixture's teardown still runs. Probed against 1.62.1, the code after await use() ran for a test that failed an assertion and for a test that ran out of time, and the seeded record was deleted both times. It did not run when the worker process died mid-test, and that record was still in the database after the run finished. A cancelled run is the fourth case and it splits: the fixture's teardown ran, the test was reported as interrupted rather than failed, and the teardown project did not run at all, which is the opposite of the worker case. So treat teardown as something that can be missed: give records identifiers you can find later, and put a sweeper outside the suite if the environment is permanent.

Can I just mock the API instead of creating test data?

For a question about what the screen does with a given response, yes, and it is often the only way to reach an error path. It cannot tell you whether your system produces that response, because the fixture and the assertion were written by the same person on the same afternoon. Which endpoints qualify is its own decision, and the mocking article makes it.

Tell us where your test data comes from

The useful first email is how the suite gets the records it acts on today — seeded over the API, driven through the interface, a fixed set of accounts in a JSON file, or a database somebody restores by hand — and whether anything outside your team uses the same environment. That is enough to say which of the three doors above the suite should be on. If you would rather start from what you have, the suite audit is an optional route in: one of the questions its report is written to answer is which tests flake and why, and state shared between specs is one of the causes it reads for.