Data-Driven Testing with Playwright
Read your test cases from a JSON file and loop over them. One test definition, many rows of input, and clear per-row reporting when something breaks.
1 Goal
By the end of this exercise you will have a Playwright project that reads login scenarios from data/logins.json and runs the same test body once per scenario. When one row fails, Playwright's reporter will name the exact row — so you never have to add console.log statements to figure out which input broke things. You will target saucedemo.com, the same public test site from Practice 01.
Time: about 30 minutes. Free tools only.
2 Install Node.js and Playwright
If you completed Practice 01 you already have Node.js installed. Skip to step 3. Otherwise:
-
Install Node.js 20 LTS or newer
Download from nodejs.org/en/download.
node --version npm --version
Expect
v20.x.xand10.x.x.node --version npm --version
Or via Homebrew:
brew install node@20.curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs node --version
For Debian/Ubuntu. Use
dnfon Fedora/RHEL.
3 Project setup
Create the project and install Playwright (the commands are identical across Windows, macOS, and Linux):
mkdir data-driven cd data-driven npm init -y npm init playwright@latest -- --quiet --browser=chromium --install-deps
Delete the auto-generated tests/example.spec.js and tests-examples/ folder. Then create two new files: one for the data, one for the test.
Your target tree:
[
{
"name": "standard user succeeds",
"username": "standard_user",
"password": "secret_sauce",
"expect": "success"
},
{
"name": "locked out user is blocked",
"username": "locked_out_user",
"password": "secret_sauce",
"expect": "locked"
},
{
"name": "wrong password is rejected",
"username": "standard_user",
"password": "wrong_password",
"expect": "bad_credentials"
},
{
"name": "empty username is rejected",
"username": "",
"password": "secret_sauce",
"expect": "username_required"
},
{
"name": "problem user succeeds",
"username": "problem_user",
"password": "secret_sauce",
"expect": "success"
}
]Each row has four fields: a human-readable name for the reporter, the two inputs, and an expect tag that tells the test which outcome to assert. Keeping the data shape this simple is deliberate — it is easy to edit, easy to diff in a PR review, and easy to generate from a spreadsheet.
4 Write the script
Paste this into tests/login.spec.js:
const { test, expect } = require('@playwright/test');
const path = require('path');
const scenarios = require(path.join(__dirname, '..', 'data', 'logins.json'));
const expectedError = {
locked: /locked out/i,
bad_credentials: /Username and password do not match/i,
username_required: /Username is required/i,
};
test.describe('Saucedemo login — data-driven', () => {
for (const row of scenarios) {
test(row.name, async ({ page }) => {
await page.goto('https://www.saucedemo.com/');
await page.locator('[data-test="username"]').fill(row.username);
await page.locator('[data-test="password"]').fill(row.password);
await page.locator('[data-test="login-button"]').click();
if (row.expect === 'success') {
await expect(page).toHaveURL(/inventory\.html/);
} else {
const error = page.locator('[data-test="error"]');
await expect(error).toBeVisible();
await expect(error).toContainText(expectedError[row.expect]);
}
});
}
});Reading the script, block by block
The require at the top loads data/logins.json relative to the test file. path.join with __dirname makes the resolution work regardless of where you run Playwright from — avoids the classic "works on my machine but not in CI" path bug.
The expectedError lookup maps each expect tag to the regex we assert against the error banner. Keeping the regexes in one object (not scattered through the loop) means a copy-change in the saucedemo UI requires exactly one edit.
The for...of loop is the heart of the pattern. Playwright's test() function is just a registration call — each iteration of the loop registers a separate test with a unique title. That is why the reporter names each row: standard user succeeds, locked out user is blocked, etc. Not test 1, test 2.
The if inside each test branches on the expected outcome. Keep the test body thin — more complex scenarios should push logic into page objects (see Practice 01) rather than piling if-branches here.
papaparse — the loop structure stays identical.
5 Run & verify
From the project root:
npx playwright test
Expected output:
Running 5 tests using 5 workers 5 passed (5.3s) To open last HTML report run: npx playwright show-report
Five tests, one per row. For a nicer breakdown with each scenario's title listed:
npx playwright test --reporter=list
✓ tests/login.spec.js:9:5 › standard user succeeds (1.4s) ✓ tests/login.spec.js:9:5 › locked out user is blocked (1.2s) ✓ tests/login.spec.js:9:5 › wrong password is rejected (1.1s) ✓ tests/login.spec.js:9:5 › empty username is rejected (0.9s) ✓ tests/login.spec.js:9:5 › problem user succeeds (1.6s) 5 passed (5.3s)
Try breaking one row deliberately: change the password for standard_user in logins.json to wrong and rerun. You should see exactly that scenario marked red, with a diff showing the expected URL vs. the actual error state. The other four scenarios stay green. That is the payoff of data-driven — one row's failure is one row's failure, not the whole file.
require is caching the old file — run npx playwright test --no-cache or stop/restart the watcher.
6 Troubleshooting
Error: "Cannot find module '../data/logins.json'"
Two likely causes:
- The file is at
data/Logins.jsonordata/logins.JSON. Linux and macOS treat those as different files. Match the exact case shown in step 3. - You put
logins.jsonnext tologin.spec.jsinstead of in a siblingdata/folder. Either move the file, or change therequirepath to./logins.json.
Error: "Test title is required" or "must call test() at top level"
Playwright requires every call to test() to happen synchronously at module load time. If you wrapped the for loop inside an async function or a fs.readFile callback, Playwright has already finished collecting tests by the time your loop runs.
Fix: use the synchronous require('./data/logins.json') as shown, or fs.readFileSync(...). Both run at import time, which is what Playwright needs.
All 5 tests get the same title (only the last row shows up)
You used var or a re-assigned variable inside the loop so all closures share a reference to the final value. Either use const/let inside a for...of loop (as shown), or switch to scenarios.forEach(row => ...). Both create a fresh binding per iteration.
Tests timeout after 30s with no browser visible
Browsers did not install. Playwright downloads them separately from the npm install:
npx playwright install chromium npx playwright install-deps chromium
The second command is Linux-only and installs system libraries the browser needs.
7 Challenge
Combine POM and data-driven
Easy: Add three more rows to logins.json covering edge cases you haven't tested yet (whitespace in the username, extremely long password, SQL-injection-looking string). Run the suite — which rows behave as you expected?
Harder: Combine this exercise with Practice 01. Move the selectors and the login flow into a LoginPage class, and shrink the test body to:
test(row.name, async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login(row.username, row.password);
await assertOutcome(page, login, row.expect);
});The helper assertOutcome sits outside the test and handles the four cases. Now a new scenario is one JSON row — no code changes needed.