I still get messages that start with a screenshot and a sinking feeling.
The client clicked “Pay now.” The button spun. Nothing happened. Or worse: the order looked placed on their screen, then the confirmation email never arrived. Nobody broke the homepage. Nobody shipped a red console error on load. A real user path just failed in the quiet middle of the product.

That is the gap end-to-end tests are for.
In 2026, Playwright is still the tool I reach for when a marketing site, a WordPress checkout, or a Next.js app needs proof that the flows people pay for still work. Not a pile of brittle CSS selectors. Not a CI job that flakes three times and gets muted. A small suite that behaves like a careful human: find the button by its name, wait for the page to settle the way Playwright already knows how, assert what the user can see, and leave a trace when something fails.
This is the practical playbook I use on client projects and on my own work: how to structure a Playwright project that stays maintainable, how to pick locators that survive redesigns, how to use web-first assertions instead of sleep timers, how to share auth with storage state, how to seed data through the API, how to debug with the trace viewer, and how to run the suite on CI without burning an hour of minutes on every pull request.
Why end-to-end still matters when you already have unit tests
Unit tests are great at proving a function returns the right shape. They are terrible at proving that the submit button is covered by a sticky cookie banner, that the success toast never appears because the API returned 422, or that the mobile menu traps keyboard focus after login.
Clients do not experience your reducer. They experience a path:
- Land on a page
- Find the thing they came for
- Fill a form or click a primary action
- See a clear result
Playwright sits in that path. It drives a real browser. It sees the same DOM, the same network, the same layout quirks. When you assert on user-visible behavior, you catch the failures that never show up in a Jest green checkmark.
I do not replace unit or component tests with Playwright. I reserve Playwright for the flows that hurt when they break: signup, login, checkout, contact forms that actually send, account settings that save, and the one admin action your client uses every Monday morning.
Install a boring, modern baseline
Start from the official runner. Keep the first commit boring on purpose.
npm init -y
npm install -D @playwright/test
npx playwright install
A minimal playwright.config.ts that has served me well:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 7'] } },
],
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
});
A few opinions baked into that file:
- Trace on first retry, not on every run. Traces are gold for debugging and expensive if you keep them always-on in CI.
- Retries only in CI. Locally you want the flake to slap you in the face.
- One desktop and one mobile project is enough for most marketing and product sites. Add Firefox and WebKit when the client actually has browser-specific bugs, not because a checklist told you to.
baseURLkeeps every test free of hardcoded hosts so staging and preview URLs are just an env var away.
Write the first test like a user, not like a CSS engineer

Here is the shape I want every new test to follow.
import { test, expect } from '@playwright/test';
test('contact form shows a success message after submit', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Name').fill('Darshan Panchasara');
await page.getByLabel('Email').fill('hello@example.com');
await page.getByLabel('Message').fill('I need a fast brochure site for a launch next month.');
await page.getByRole('button', { name: 'Send message' }).click();
await expect(page.getByRole('status')).toContainText('Thanks');
await expect(page).toHaveURL(/contact/);
});
Notice what is missing: no waitForTimeout. No .contact-form > div:nth-child(2) input. No screenshot diff of the entire page for a form submit. The test names the controls the way a person would, clicks the button by its accessible name, and asserts on a status region the UI already exposes for accessibility.
That is not just nicer to read. It is more stable. When a designer changes a class name, getByRole and getByLabel keep working. When someone removes the accessible name, the test fails for a reason you should care about anyway.
Locator priority that survives redesigns
Playwright’s own guidance still holds in 2026. Prefer locators in this order:
getByRolewith accessible namegetByLabelfor form controlsgetByPlaceholderonly when there is truly no label (and then fix the label)getByTextfor unique copygetByTestIdas an explicit test contract when the UI has no stable accessible name
Avoid:
- Long CSS chains tied to layout
- XPath that walks the DOM tree
- Matching on random generated class hashes from CSS-in-JS
When two buttons share a name, narrow with a parent locator instead of inventing a fragile selector:
const dialog = page.getByRole('dialog', { name: 'Delete project' });
await dialog.getByRole('button', { name: 'Delete' }).click();
If you catch yourself writing page.locator('.btn.btn-primary.ml-2'), stop and ask whether the control has a role and a name. If it does not, that is a product bug wearing a test problem costume.
Web-first assertions beat sleep every time

The single biggest source of flaky Playwright suites I inherit is manual waiting.
// Fragile: checks once, races the UI
expect(await page.getByText('Payment confirmed').isVisible()).toBe(true);
// Stable: retries until true or timeout
await expect(page.getByText('Payment confirmed')).toBeVisible();
Playwright’s expect matchers for the page are asynchronous on purpose. They poll. They re-query the locator. They wait for the UI to catch up. That is the feature. Use it.
Good assertions for real flows:
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByLabel('Email')).toHaveValue('hello@example.com');
await expect(page.getByRole('alert')).toContainText('Card declined');
await expect(page.getByRole('button', { name: 'Pay now' })).toBeDisabled();
Soft assertions exist when you want to collect several visual checks before failing. I use them sparingly on checklist-style pages. For a checkout path, I usually want the first failure to stop the test so the trace shows the real break.
Stop sharing state between tests
Playwright gives every test a fresh browser context by default. Keep that gift.
Bad patterns I delete on sight:
- Test A creates a user; test B logs in as that user
- A
beforeAllthat mutates a shared database row every test depends on - Relying on sort order of a list that other parallel workers also write to
Good patterns:
- Each test creates its own data through an API helper
- Each test cleans up after itself when the environment is shared
- Auth is injected as storage state, not as a click path in every file
Isolation is what lets you turn on fullyParallel without inventing a new flake every week.
Auth the smart way: one login, many tests
Logging in through the UI for every test is slow and noisy. Playwright supports saving authenticated storage and reusing it.
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_EMAIL!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
Wire a setup project in config, then point your logged-in projects at that file. Critical flows like “login itself” still get a dedicated UI test. Everything else starts already signed in.
For WordPress admin or headless CMS dashboards, the same idea applies: capture cookies once, reuse them, and keep one pure login test so you still notice when the sign-in form breaks.
Seed through the API, assert through the UI
If your test spends two minutes clicking through a wizard only to reach the screen you care about, you are testing the wizard every time whether you meant to or not.
Prefer this split:
- Create fixtures with
request(Playwright’s APIRequestContext) or a small Node helper against your staging API - Navigate straight to the URL under test
- Assert the UI the human sees
test('invoice detail shows the paid badge', async ({ page, request }) => {
const created = await request.post('/api/test/invoices', {
data: { status: 'paid', customer: 'Acme' },
});
expect(created.ok()).toBeTruthy();
const { id } = await created.json();
await page.goto(`/invoices/${id}`);
await expect(page.getByText('Paid')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Acme' })).toBeVisible();
});
You still keep one or two full UI journeys for the wizard. You do not make every invoice assertion pay the wizard tax.
Page objects and fixtures without ceremony
I use page objects when a screen has several repeated actions. I do not build a museum of classes for a five-line test.
// e2e/pages/checkout.ts
import { type Page, expect } from '@playwright/test';
export class CheckoutPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto('/checkout');
}
async fillCard(details: { number: string; exp: string; cvc: string }) {
await this.page.getByLabel('Card number').fill(details.number);
await this.page.getByLabel('Expiry').fill(details.exp);
await this.page.getByLabel('CVC').fill(details.cvc);
}
async pay() {
await this.page.getByRole('button', { name: 'Pay now' }).click();
}
async expectSuccess() {
await expect(this.page.getByRole('heading', { name: 'Payment confirmed' })).toBeVisible();
}
}
Then a fixture can hand you a ready page:
import { test as base } from '@playwright/test';
import { CheckoutPage } from './pages/checkout';
export const test = base.extend<{ checkout: CheckoutPage }>({
checkout: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
});
Fixtures shine for auth, seeded data, and page objects. They are overkill for a single visit-and-assert smoke test. Use judgment.
Network control when third parties are flaky
Do not assert against a live payment provider, a random weather API, or a cookie-consent CDN you do not own. Stub the responses you need.
await page.route('**/api/pay', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, id: 'pay_test_123' }),
});
});
Or abort analytics so they cannot flake your suite:
await page.route('**/collect**', (route) => route.abort());
I keep third-party stubs next to the tests that need them. The goal is determinism, not a full mock of the internet.
Debugging: the trace viewer is the product
When a CI job fails, screenshots help a little. The Playwright trace helps a lot.
Open a trace from the HTML report or directly:
npx playwright show-trace trace.zip
You get a timeline, DOM snapshots before and after each action, console logs, and network details. Most “it failed on CI only” mysteries die here: a slower API, a different redirect, a hydration mismatch, a button that was covered for 200ms.
Locally I still use UI Mode when I am authoring:
npx playwright test --ui
And codegen when I am exploring a messy admin screen:
npx playwright codegen https://staging.example.com/admin
Codegen is a starting point, not a style guide. Replace the brittle bits with roles and labels before you commit.
CI that finishes before people lose interest
A suite that takes forty minutes will get skipped. Keep the default pull-request run focused.
Practical defaults I ship:
- Chromium on every PR
- Full browser matrix nightly or on main
- Shard when the suite grows past a few minutes:
npx playwright test --shard=1/3 - Install only the browsers you run:
npx playwright install chromium --with-deps - Fail the build on
test.onlywithforbidOnlyin CI - Upload the HTML report and traces as artifacts
GitHub Actions is fine. So is any Linux runner. Linux is cheaper than macOS agents for the same Chromium coverage. Developers can use whatever OS they like locally.
Also set a real PLAYWRIGHT_BASE_URL for preview deploys so you test the branch the PR actually built, not last week’s staging.
What to test (and what to leave alone)
High value:
- Authentication and session expiry
- Checkout and billing changes
- Lead forms that create CRM rows or send email
- Critical WordPress template paths on a headless front end
- Permissions: a user must not see another team’s data
- The “happy path” plus one meaningful failure path (declined card, invalid coupon)
Low value or harmful:
- Asserting pixel-perfect marketing animations on every commit
- Crawling every footer link to third-party sites
- Duplicating every unit case at the browser layer
- Visual regression of entire dashboards without stable fixtures
If a test cannot name the user pain it prevents, cut it.
A client-ready checklist before you call coverage “done”

When I hand a Playwright suite to a client or keep one running for my own site, I want these boxes checked:
- Smoke tests for the top three revenue or lead paths
- Locators based on roles and labels, not CSS trivia
- Web-first assertions everywhere that touches the page
- Isolated data per test, API seeding where possible
- Auth via storage state, plus one dedicated login UI test
- Traces on retry, HTML report uploaded in CI
- Chromium on PRs, broader matrix on a schedule
- Secrets in CI variables, never in the repo
- A README with
npm run test:e2e, env vars, and how to open a trace - Owners: someone gets paged (even softly) when main is red
That list is not academic. It is the difference between a suite that protects launches and a suite that becomes folklore.
Common failure modes I still see in 2026
Flakes from hard waits. Replace waitForTimeout with assertions and locator auto-waiting.
Tests coupled to animation timing. Prefer asserting on the final accessible state, or disable animations in test CSS when the product allows it.
Shared email inboxes. Use unique addresses per run (user+${Date.now()}@example.com) or an API that marks users verified without mail.
Staging data someone else deleted. Seed what you need. Do not assume yesterday’s demo account still exists.
Over-mocking the app under test. Stub third parties. Do not stub away the feature you claim to verify.
Ignoring accessibility in locators. If getByRole cannot find the button, users who rely on assistive tech may struggle too. Fix the UI.
A compact example: login, create, verify
Putting the pieces together for a tiny project-management flow:
import { test, expect } from '@playwright/test';
test.describe('projects', () => {
test.use({ storageState: 'e2e/.auth/user.json' });
test('user can create a project and see it in the list', async ({ page, request }) => {
const name = `Launch site ${Date.now()}`;
await page.goto('/projects');
await page.getByRole('button', { name: 'New project' }).click();
await page.getByLabel('Project name').fill(name);
await page.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByRole('heading', { name })).toBeVisible();
await page.goto('/projects');
await expect(page.getByRole('link', { name })).toBeVisible();
// optional API cleanup
const list = await request.get('/api/projects');
const projects = await list.json();
const row = projects.find((p: { name: string }) => p.name === name);
if (row) {
await request.delete(`/api/projects/${row.id}`);
}
});
});
Short. Readable. Isolated. Asserts what a client would notice if it broke.
How I introduce Playwright on an existing client site
Most retainers do not start from a greenfield repo. They start from a live WordPress site, a Next.js app with uneven tests, or a Webflow-to-custom rebuild.
My rollout looks like this:
- Pick three flows that would embarrass us if they failed on a Friday
- Add Playwright with Chromium only
- Get those three tests green on CI against staging
- Add auth storage state once login is stable
- Expand only when a real bug escapes or a new revenue path ships
I would rather have three trustworthy tests than thirty that everyone ignores. Coverage theater helps nobody when the checkout is broken.
For headless WordPress setups like the one behind darshanpanchasara.com, Playwright is also how I confirm that a published post renders the intended sections on the public front end after the admin API says “publish.” The CMS can be green while the front end cache or the headless query is wrong. An e2e check closes that loop.
Performance and cost notes that actually matter
Browsers are heavy. A few habits keep bills and minutes down:
- Run fewer projects on pull requests
- Share auth state
- Seed via API
- Avoid full-page screenshot comparison as your primary signal
- Keep tests short and parallel
- Fail fast on
test.onlyand obvious setup errors
If a suite is slow, profile the waits before you buy more CI shards. Slow usually means UI setup you should replace with API seeding, or assertions that wait the full timeout because the locator is wrong.
What “done” looks like for a 2026 Playwright suite
Done is not “we installed Playwright.” Done looks like this:
- A developer can clone, set two env vars, and run the smoke suite locally
- CI runs on every PR and finishes quickly enough that people wait for it
- Failures include a trace someone can open without guessing
- The suite failed once in the last month for a real bug, and that felt useful
- The client or your future self can tell which user flows are protected
That is the bar I write toward.
Closing
Playwright will not replace careful engineering. It will catch the broken user flow before your client records a Loom about it.
Start small. Prefer role-based locators. Assert with web-first expectations. Isolate data. Save auth. Stub the third parties you do not own. Keep traces for the failures. Run Chromium on every pull request and grow the matrix only when it pays rent.
If you ship websites and web apps for a living, a focused Playwright suite is one of the cheapest ways to protect trust. The goal is not a wall of green checks. The goal is never having to say, “It worked on my machine,” after a customer already tried to pay.
When you are ready to harden the flows that make you money, write the first three tests this week. Your future Friday afternoon will thank you.




















