Playwright Data-Driven Testing
Run one Playwright test against many inputs with readable cases, projects, and fixtures. Includes TypeScript examples, isolation advice, and troubleshooting.
Playwright data-driven testing means running the same behavior check with multiple records or configuration values. For a small set of inputs, keep an array of records beside the test and declare one uniquely named test per record. Use projects when the variation is a browser, device, environment, timeout, retry policy, or another configuration option. Use fixtures when test data needs setup, teardown, or a reusable lifecycle.
This structure gives every failure a clear case name, keeps inputs and expected outcomes visible, and lets Playwright report each record independently.
Choose the right pattern
| Need | Pattern | Design focus |
|---|---|---|
| Several inputs and expected outputs for one behavior | Array of records and one test declaration per record | Unique names, independent state, readable expectations |
| The same tests under browsers, devices, environments, or option values | Projects with configuration or option fixtures | Configuration differences, report clarity, runtime cost |
| Repeatable setup or a resource with cleanup | Fixtures | Scope, teardown, isolation, and reuse |
Pattern 1: one test per data record
The official Playwright parameterization guide uses an array of records and interpolates a distinguishing value into each test title. Keep shared hooks outside the loop when they should run at the common suite scope. Test names must be unique so a failure identifies the exact row.
import { test, expect } from '@playwright/test';
type GreetingCase = {
name: string;
expected: string;
};
const cases: GreetingCase[] = [
{ name: 'Alice', expected: 'Hello, Alice!' },
{ name: 'Bob', expected: 'Hello, Bob!' },
{ name: 'Chandra', expected: 'Hello, Chandra!' },
];
test.beforeEach(async ({ page }) => {
await page.goto('/greeting');
});
for (const data of cases) {
test(`greets ${data.name}`, async ({ page }) => {
await page.getByLabel('Name').fill(data.name);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('status')).toHaveText(data.expected);
});
}
Each record becomes a separate test at collection time. A failure such as greets Bob points directly to the input and expected output that failed.
Make records useful
- Include all values needed to reproduce the case, including expected status or message.
- Use a stable identifier in the title, such as a slug or case ID.
- Avoid duplicate titles; reports and retries become ambiguous when names collide.
- Keep the table small and immutable when it is only test input. Move resource creation into setup when records represent server-side state.
Parameterized navigation and assertions
const cases = [
{ id: 'valid-card', number: '4242424242424242', expectedUrl: /success/ },
{ id: 'declined-card', number: '4000000000000002', expectedUrl: /declined/ },
];
for (const data of cases) {
test(`checkout: ${data.id}`, async ({ page }) => {
await page.goto('/checkout');
await page.getByLabel('Card number').fill(data.number);
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page).toHaveURL(data.expectedUrl);
});
}
Pattern 2: projects for configuration values
Projects are logical groups of tests with shared configuration. They can represent browsers and devices, but also environments, custom options, timeouts, and retry policies. The projects guide also describes dependencies for setup and teardown.
Define an option fixture, then set that option differently in named projects:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'chromium-us',
use: { ...devices['Desktop Chrome'], locale: 'en-US' },
},
{
name: 'chromium-fr',
use: { ...devices['Desktop Chrome'], locale: 'fr-FR' },
},
],
});
For an application-specific value, extend the test with an option:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
export type Options = { apiBaseURL: string };
export const test = base.extend<Options>({
apiBaseURL: ['', { option: true }],
});
export { expect } from '@playwright/test';
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'staging', use: { apiBaseURL: 'https://staging.example.test' } },
{ name: 'production-like', use: { apiBaseURL: 'https://prod.example.test' } },
],
});
// tests/profile.spec.ts
import { test, expect } from './fixtures';
test('loads the profile from the configured environment', async ({ page, apiBaseURL }) => {
await page.goto(`${apiBaseURL}/profile`);
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
});
Run one project with npx playwright test --project=staging, or run all projects with npx playwright test. Project names appear in reports, making configuration-specific failures easy to filter.
Pattern 3: fixtures for setup and reusable data
Fixtures provide resources on demand, are composable, and are isolated between tests according to the fixture guide. Match fixture scope to the resource lifecycle. Mutable per-test records should not accidentally become shared state.
// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';
type User = { email: string; password: string };
type Fixtures = {
user: User;
};
export const test = base.extend<Fixtures>({
user: async ({ request }, use, testInfo) => {
const email = `pw-${testInfo.workerIndex}-${testInfo.testId}@example.test`;
const password = 'test-password';
const response = await request.post('/api/test-users', {
data: { email, password },
});
if (!response.ok()) throw new Error(`Could not create user: ${response.status()}`);
await use({ email, password });
await request.delete(`/api/test-users/${encodeURIComponent(email)}`);
},
});
export { expect };
// tests/login.spec.ts
import { test, expect } from './fixtures';
test('logs in with an isolated user', async ({ page, user }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
If setup is expensive but safe to share, use an appropriate broader fixture scope and explicit teardown. For state that must be isolated per test, create unique records or reset the state before each case.
Combining records, projects, and fixtures
These dimensions are multiplicative: three records across two projects produce six test cases. That is useful when each combination is meaningful, but it can create unnecessary runtime and duplicate failures. Keep the data table for behavior variation, projects for environment variation, and fixtures for lifecycle-managed resources.
import { test, expect } from './fixtures';
const cases = [
{ id: 'empty-search', query: '', expected: 'Enter a search term' },
{ id: 'known-search', query: 'playwright', expected: 'Results' },
];
for (const data of cases) {
test(`searches: ${data.id}`, async ({ page, user }) => {
await page.goto('/search');
await page.getByLabel('Search').fill(data.query);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('status')).toContainText(data.expected);
});
}
Isolation, order, and parallel execution
Playwright’s best practices recommend that tests be completely isolated, with their own relevant local storage, session storage, data, and cookies. Assert user-visible behavior rather than implementation details. The parallelism guide notes that tests in a file run in order by default while files run in parallel; scheduling is not a substitute for isolation.
- Give each case independent records, accounts, or namespaces.
- Do not read data created by a previous test unless a project dependency explicitly owns that setup.
- Use cleanup in fixture teardown or generate disposable records with a unique suffix.
- Expect parallel workers to expose hidden collisions. Re-run with
--workers=1only to diagnose; fix the shared-state design rather than depending on serial execution. - Use retries for transient infrastructure failures, not to hide deterministic assertion failures.
Loading data from files or generated sources
Playwright does not require a particular CSV, spreadsheet, or external data provider. Parse such data before test declarations are collected, validate its shape, and convert it to records with stable IDs. Keep secrets out of checked-in fixtures; load them from environment variables or a secret store.
import { test, expect } from '@playwright/test';
import cases from './fixtures/search-cases.json';
type SearchCase = { id: string; query: string; expectedCount: number };
for (const data of cases as SearchCase[]) {
test(`search result count: ${data.id}`, async ({ page }) => {
await page.goto('/search');
await page.getByLabel('Search').fill(data.query);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByTestId('result-count')).toHaveText(String(data.expectedCount));
});
}
Run, filter, and debug data-driven tests
# Run every case
npx playwright test
# Run a project
npx playwright test --project=chromium-us
# Run one case by its title
npx playwright test -g "greets Bob"
# See the browser while debugging
npx playwright test -g "greets Bob" --headed --debug
# Run a single file
npx playwright test tests/greeting.spec.ts
Use trace, screenshot, and video settings in your Playwright configuration when diagnosing a failing record. The test title and project name should carry enough identity that an artifact can be tied back to one input row.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Only the last record runs | The test declaration is outside the loop or mutable variables are reused. | Declare a test inside the loop and bind the current record with const. |
| Failures are hard to identify | Every test has the same title. | Interpolate a stable case ID or input label into the title. |
| Cases pass alone but fail together | Shared browser, account, database row, cookie, or server state. | Use isolated fixtures, unique records, reset APIs, and teardown. |
| Works with one worker, fails in parallel | Tests depend on order or collide across workers. | Partition data by worker or test ID and remove ordering assumptions. |
| Project option is undefined | The option fixture was not declared or the test imported the base test. | Declare the option with { option: true } and import the extended test. |
| Fixture cleanup does not run | Setup throws before use, or cleanup is not after it. |
Check setup errors and place deletion/reset logic after await use(...). |
| Too many duplicate runs | Records, projects, and browser matrices multiply unexpectedly. | Calculate the combination count and keep only meaningful dimensions in each suite. |
| Flaky external data | The test depends on mutable third-party content. | Use a controlled test environment or assert stable, user-visible invariants. |
Performance, reliability, and cost
- Collection cost: generating thousands of declarations increases discovery and report size. Partition large datasets or use a smaller representative suite for pull requests.
- Browser cost: projects multiply browser launches and page actions. Run the full matrix in CI and a focused project locally.
- Fixture cost: create expensive resources at the narrowest safe scope. Sharing mutable state saves time but weakens isolation.
- Parallelism: parallel workers reduce wall-clock time only when records are independent and the environment can handle the load.
- Reliability: stable IDs, explicit expected outcomes, controlled setup, and observable assertions make failures actionable.
- Retries: use them to recover from transient infrastructure faults and inspect traces for recurring failures.
Or skip the browser setup
If your goal is to capture a page for a test artifact, visual baseline, or review, ScreenshotNeo provides a single screenshot API request instead of maintaining browser capture code. Read the ScreenshotNeo API documentation for the full option set.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Before capture, cookie and consent banners, newsletter popups, and chat widgets are removed. Bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing, and response headers identify the page verdict and whether it was billed. ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.
Start with 1,000 free screenshots a month—no card required.
FAQ
Does Playwright have a built-in data provider?
The documented approach is to create test declarations from your own records. You can parse JSON, CSV, or another source before declarations are collected, but the source format is your choice.
Should I use a fixture or a project?
Use a project for a configuration dimension such as browser, device, environment, or option value. Use a fixture for a resource that needs setup, reuse, isolation, or teardown.
Can I share one logged-in account across cases?
Only when the cases are read-only and the shared state cannot change their outcome. For mutable workflows, create isolated accounts or reset state per test.
How do I rerun one failing data row?
Give the row a unique title and run npx playwright test -g "that title", optionally with --project and --debug.
How do I prevent a huge matrix?
Keep behavior records, projects, and fixtures focused on meaningful combinations. Use a representative data subset for fast feedback and a broader matrix in scheduled CI runs.
Key takeaways
- Declare one uniquely named test for each input and expected result.
- Use projects for configuration variation and fixtures for lifecycle-managed setup.
- Design every case to run independently; parallel scheduling will reveal hidden coupling.
- Keep data explicit, assertions observable, and the generated matrix proportional to the risk it covers.


