From npm init to a Green Run: Installing and Configuring Playwright
Playwright's install path is deliberately short, but each step produces an artifact you should understand, because every later debugging session assumes you know where it came from. Start in an empty project directory:
npm init -y
npm install -D @playwright/test
npx playwright install --with-deps chromium
The first command adds the @playwright/test runner as a dev dependency — note that it is the runner, not just a library: it discovers specs, manages workers, captures artifacts, and reports results. The second downloads browser binaries into a per-user cache (%USERPROFILE%\AppData\Local\ms-playwright on Windows, ~/.cache/ms-playwright on Linux/macOS) rather than into node_modules, so reinstalling packages never re-downloads 400 MB of Chromium. On Linux CI, --with-deps also installs the system libraries headless Chromium needs; without them the browser dies with an opaque Host system is missing dependencies error.
Next, generate the config and a sample spec:
npx playwright install --dry-run # lists what would be installed for this platform
npx playwright init # older scaffolds; newer versions ask during install
npx playwright test --version
The generated playwright.config.ts is the single source of truth for the suite. The parts that matter on day one:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000, // per test, not per step
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['iPhone 13'] } },
],
});
Three decisions embedded here are worth pausing on. baseURL lets every test call page.goto('/login') with a relative path, so moving environments is a config change — the configuration lesson from Automation Concepts and Frameworks applied concretely. projects are named execution targets: the same specs run three times across Chromium, Firefox, and WebKit, which is how your Tier 1 rows from Cross-Browser and Device Coverage become automated. And trace: 'on-first-retry' means the expensive recording only happens when a test is suspected flaky, keeping green runs fast.
Verify the whole pipeline before writing real tests:
npx playwright test --list # discovers specs without running them
npx playwright show-report # opens the HTML report after any run
If --list prints your spec files, the runner, config, and discovery all work. A second sanity check worth knowing: npx playwright install --dry-run prints exactly which browser revision this Playwright version expects and where it would land — the first thing to inspect when a colleague's machine reports Executable doesn't exist at ..., which almost always means either the install step was skipped or a different Playwright version bumped the expected revision.
The last check is a real browser launch — the sample example.spec.ts navigates to https://example.com and asserts the title. When it passes, you have proven browsers, runner, and assertions end to end, and every failure after this point is your test or your app, never the toolchain. One configuration habit to adopt on day one: commit playwright.config.ts, the tests/ directory, and a .gitignore that excludes test-results/, playwright-report/, and the trace zips — artifacts are local evidence, never source, and a repository that starts accumulating 40 MB of failed-run videos is a repository people stop cloning.
A First Real Spec: Navigate, Fill, Submit, Assert on What the User Sees
The sample spec proves the toolchain. The first real spec proves you can model a user journey. Take the smallest journey worth protecting — a visitor signs up for a newsletter and sees a confirmation — and write it so it reads like the feature, not like the DOM:
import { test, expect } from '@playwright/test';
test('visitor subscribes and sees confirmation', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email address').fill('qa@example.com');
await page.getByRole('button', { name: 'Subscribe' }).click();
const confirmation = page.getByRole('status');
await expect(confirmation).toBeVisible();
await expect(confirmation).toHaveText(
'Thanks! qa@example.com has been added.'
);
});
Read it in order: arrange (goto), act (fill, click), assert on what a user would actually read. The test title is behavior, not implementation — when it fails at 3 a.m., visitor subscribes and sees confirmation tells the on-call engineer what broke before they open anything.
The locators deserve attention because they are the difference between a suite that survives redesigns and one that does not. getByLabel('Email address') binds to the <label> association, so wrapping the input in different markup or changing its class does not break it. getByRole('button', { name: 'Subscribe' }) binds to the accessible role and accessible name — if the developer renames the button to Sign up, the test should fail, because the visible contract changed; if they restyle it, the test correctly keeps passing. getByRole('status') targets the role=status live region the confirmation renders into, which survives text changes you then assert explicitly.
Assertions in Playwright are deliberately "web-first": expect(toBeVisible), toHaveText, toHaveURL, toHaveValue are retrying assertions — the runner polls until the timeout instead of checking once. This is the structural fix for the beginner-suite sin of sleeping: no waitForTimeout(2000) appears anywhere above, because the assertion itself waits for the condition it is checking.
A few habits worth forming on the very first spec:
✓ Assert outcomes (visible confirmation text), not intermediates (button was clicked)
✓ Use expect.soft only when a later step needs to run after an earlier failure
✗ Never assert on transient spinner classes — spinners are implementation
✗ Never catch errors in the test to "make it pass" — that hides the defect
Data also matters even at this scale. Hard-coding qa@example.com works until the suite runs twice against one backend and the second run reports "already subscribed". The fix — creating a unique address per run — is a one-liner (qa+${Date.now()}@example.com) that plants the seed for the full fixture strategy in Fixtures and Test Data Layers.
Equally worth forming on the first spec: keep tests independent. This one creates its own state by subscribing a fresh address, so running it alone, with the whole file, or in a shuffled CI order produces the same result. A second test that reads the subscription — for instance asserting the confirmation appears in an admin list — would need that same state created in its own arrange step (or a shared fixture later), not by depending on the first test having run. Test independence is the property that lets the runner parallelize, retry, and re-run any single test on demand; a suite of chained tests can do none of those things, and it degrades into the run_tests_in_order.sh scripts that plague undisciplined teams.
Run it three ways and learn what each tells you:
npx playwright test # headless, fast, CI-shaped
npx playwright test --headed # watch the browser act
npx playwright test tests/newsletter.spec.ts --debug # step inspector + Playwright Inspector
--debug opens the inspector with step-through, watch, and record modes — it is the answer to "my test fails but I cannot see why", until traces take over that job in the next section.
Headless Locally, Headed When Debugging: Auto-Wait, Traces, and Reproduction
Day-to-day automation runs headless: no window, maximum speed, hundreds of tests in parallel on a CI machine with no display. Debugging runs headed: a real window you can watch, because the fastest way to understand a failure is often seeing where the cursor stopped. Playwright makes both modes a flag apart, and the third mode — trace replay — is where serious debugging lives.
The two modes and what each is for.
npx playwright test # headless: default locally and in CI
npx playwright test --headed --workers=1 # headed + serial: watch one test's real behavior
npx playwright test --ui # UI mode: pick tests, watch, time-travel
npx playwright show trace trace.zip # replay a recorded failure
--headed --workers=1 is the workhorse: parallel workers interleaving windows makes visual debugging meaningless, so collapse to one worker, watch the browser, and you will usually see the failure in seconds — a cookie banner covering the button, a redirect to a login page, a value typed before the field cleared its default.
Why most beginner waits disappear. Every Playwright action performs actionability checks before interacting: the element must be attached to the DOM, visible (non-empty bounding box, no visibility:hidden), stable (not animating between two positions), enabled, and able to receive events (not covered by an overlay). click() waits for all five; if an overlay still blocks the target at timeout, you get a diagnostic naming the actual obstruction — element is not receiving pointer events, because of another element is obscuring it — rather than a bare ElementNotFound. Auto-waiting also applies to locators: getByRole(...) does not throw when the element is missing; it returns a locator that resolves at assertion time, so page-load timing never races the test.
Web-first assertions complete the picture. await expect(page).toHaveURL(/dashboard/) retries until navigation lands; await expect(locator).toHaveText('Saved') retries until the text arrives; expect(locator).toBeHidden() passes as soon as the element is gone without a manual wait. The rule of thumb: if you are writing a wait, you are probably writing a redundant line — attach the condition to the assertion instead.
Traces are the failure artifact that changes debugging. With trace: 'on-first-retry' (or --trace on), each test records a network log, console output, a DOM snapshot before and after every action, and screenshots — replayable in a timeline with before/after state. Where a screenshot shows one frozen moment, a trace shows the sequence: the request that returned 404, the console error, the element that existed but was invisible. npx playwright show report opens the HTML report where every failure links to its trace, video, and screenshot — that report is the first place to look after any CI run.
Reproduce before you classify. When a test fails in CI but passes locally, the discipline is: pull the trace first, reproduce locally with --headed --workers=1, and only then decide whether the app, the test, or the environment is at fault. That three-way classification is a large enough topic to deserve its own treatment — Debugging Failing Tests — but the habit starts here: never "re-run and hope"; a rerun without a trace destroys the evidence you needed.
One config note for CI: set retries (2 in CI, 0 locally) so flaky candidates generate traces on retry, and keep reporter: [['list'], ['html', { open: 'never' }]] so the artifact exists to open. The suite you have now — config, first spec, headed debugging, trace-based triage — is the working foundation the next topics build a structure on.
Quiz
1. You run `npx playwright install chromium` and the browser lands outside node_modules. What is the practical benefit?
2. Your spec clicks Subscribe and then asserts `expect(confirmation).toHaveText('Thanks!')` with no explicit wait. Why does this not race the UI update?
3. A CI test fails but passes on your machine. What is the correct first debugging move?
4. What does the actionability check inside `locator.click()` verify before interacting?
Flashcards
Question
What does `npx playwright install` actually install, and where does it go?
Click to reveal answer
Answer
Browser binaries (Chromium, Firefox, WebKit) plus — with --with-deps on Linux — the system libraries headless Chromium requires. They go to a per-user cache (~/.cache/ms-playwright or %LOCALAPPDATA%\ms-playwright), not node_modules, so package reinstalls never re-download them and CI images can pre-bake them.
Question
Name the five actionability checks Playwright performs before `click()` interacts with an element.
Click to reveal answer
Answer
Attached (in DOM), visible (non-empty box, not visibility:hidden), stable (not mid-animation between positions), enabled, and able to receive events (topmost at the click point, not covered by an overlay). Failing any one at timeout produces a diagnostic naming the actual blocker instead of a generic not-found error.
Question
What is a web-first assertion and why does it remove explicit waits?
Click to reveal answer
Answer
expect(toBeVisible/toHaveText/haveURL/haveValue...) is a retrying assertion: the runner polls the condition until it passes or the timeout expires. Because the assertion waits for the condition it describes, tests need no waitForTimeout or manual polling — the synchronization lives with the expectation.
Question
Headless vs headed vs UI mode vs trace — when do you use each?
Click to reveal answer
Answer
Headless: default fast local/CI runs. --headed --workers=1: watch a specific test's real browser behavior. --ui: interactive pick/watch/time-travel over many tests. Trace (show trace / HTML report): replay a recorded failure — network, console, DOM snapshots per action — which is the first stop for CI failures you cannot reproduce by eye.
Question
What does `projects` in playwright.config.ts do, and how does it connect to a browser support matrix?
Click to reveal answer
Answer
Projects are named execution profiles that run the same spec files under different devices/browsers (Desktop Chrome, Desktop Firefox, iPhone 13/WebKit). Mapping Tier 1 rows of your support matrix to projects makes cross-browser coverage a one-config concern — add a project, re-run, get the matrix verified in CI.
Revision Notes
Key Takeaways
- 1. Playwright installs browsers into a shared user cache and the runner/config pair is the suite's single source of truth for timeouts, retries, reporters, and browser projects
- 2. First specs model user journeys: locate by label/role, assert outcomes a user reads, and name tests by behavior so failures self-describe
- 3. Auto-waiting (five actionability checks) plus retrying web-first assertions structurally eliminate sleep-based waits from the suite
- 4. Traces record network, console, and DOM snapshots per action — they, not re-run-and-hope, are the first response to a CI failure
- 5. Headless for speed, headed+serial for watching, UI mode for exploring, trace replay for forensics — each mode answers a different question
Interview Tips
- • "How does Playwright avoid flaky waits?" — cite the five actionability checks before interaction and retrying web-first assertions; contrast with explicit WebDriverWait/Sleep in older suites
- • "Your test passes locally, fails in CI — how do you debug?" — trace first (evidence), then reproduce headed with workers=1, then classify product/test/environment; never re-run blindly
- • "What locators did you use and why?" — getByLabel and getByRole bind to semantics, so restyling never breaks tests while renaming visible text correctly does
- • "How would you run this across browsers?" — projects in playwright.config.ts mapped to Tier 1 of the support matrix, executed in parallel in CI with traces on retry
Cheat Sheet
First Playwright Tests
Setup:
npm install -D @playwright/test
npx playwright install --with-deps chromium # browser cache, not node_modules
npx playwright test --list # verify discovery
Spec shape: arrange → act → assert on user-visible outcomes.
test('visitor subscribes and sees confirmation', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email address').fill('qa@example.com');
await page.getByRole('button', { name: 'Subscribe' }).click();
await expect(page.getByRole('status'))
.toHaveText('Thanks! qa@example.com has been added.');
});
Config essentials: baseURL (relative gotos), projects (matrix → browsers), retries: CI ? 2 : 0, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure'.
Debugging ladder:
| Signal | Command |
|---|---|
| Quick look | npx playwright test --headed --workers=1 |
| Interactive | npx playwright test --ui / --debug |
| CI failure | npx playwright show report → trace replay |
Wait rules: actionability checks + retrying assertions ⇒ never waitForTimeout; a wait you write is usually redundant — attach the condition to the assertion.