Skip to content
QA Workflow Assistant

Blog

Playwright Testing Tutorial: End-to-End Tests QA Teams Actually Maintain

A practical Playwright testing tutorial for QA and SDET teams: TypeScript setup, locators, auto-waiting, fixtures, API plus UI flows, traces, and CI patterns that reduce flake.

QA Workflow Assistant10 min read
  • playwright
  • e2e
  • test-automation
  • typescript
  • qa

End-to-end suites fail for two reasons more often than “the framework is bad”: the product is unstable under load, or the tests encode brittle assumptions. This Playwright testing tutorial focuses on the second problem—how QA and SDET teams write Playwright end-to-end tests that survive refactors, survive CI, and stay readable when someone else owns the failure at 2 a.m.

If you are still choosing where automation fits in your overall strategy, start with the broader automation testing guide. This article assumes you already know you need browser-level coverage and want Playwright specifically.

Why Playwright for QA teams

Playwright is a Node-based browser automation library with first-class support for Chromium, Firefox, and WebKit. For QA teams, the practical advantages are less about marketing checkboxes and more about day-to-day maintainability:

  • Auto-waiting built into actions and assertions. You spend less time writing sleep and more time describing intent.
  • Reliable locators for accessible UI. Role, label, and text locators encourage selectors that match how users and assistive tech see the page.
  • Trace and video tooling. When a CI job fails, you can often answer “what happened?” without reproducing locally first.
  • Browser contexts as isolation. Parallel workers can share a browser process while keeping cookies and storage separate.
  • APIRequestContext beside UI. You can seed data over HTTP, then assert in the browser—useful when UI-only setup is slow or flaky.

Playwright is not magic. It will not fix ambiguous requirements, shared test environments with colliding data, or suites that try to assert every pixel. Treat it as a precise tool for critical user journeys and high-risk regressions, not as a replacement for unit tests or thoughtful smoke vs sanity selection.

Compared with Cypress, Playwright’s multi-browser and multi-tab model is often a better fit for apps that open new windows, download files, or need true cross-browser CI. We cover Cypress on its own terms in the Cypress end-to-end testing guide; pick based on your app’s constraints, not blog rankings.

Setup: project, browsers, and TypeScript

You need Node.js installed. From an empty folder (or your app monorepo’s e2e package):

npm init -y
npm install -D @playwright/test
npx playwright install

npx playwright install downloads browser binaries. In CI you typically install only what you run, for example Chromium, to keep jobs faster.

Generate a starter config if you prefer scaffolding:

npm create playwright@latest

A minimal playwright.config.ts that works for most QA suites:

import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  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.BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
  ],
});

Notes that matter in real suites:

  • Set baseURL so tests use relative paths like /login instead of hard-coding hosts.
  • Prefer trace: "on-first-retry" over always-on traces unless you are debugging a hard flake—traces are large.
  • Keep retries for CI only. Local retries hide problems you should fix while developing the test.

Add scripts to package.json:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:report": "playwright show-report"
  }
}

Your first end-to-end test

Create tests/login.spec.ts. This example mirrors the kind of case you would document in login test cases, then automate once the flow is stable:

import { test, expect } from "@playwright/test";
 
test.describe("Login", () => {
  test("valid user reaches dashboard", async ({ page }) => {
    await page.goto("/login");
 
    await page.getByLabel("Email").fill("qa.user@example.com");
    await page.getByLabel("Password").fill("CorrectHorse-Battery1");
    await page.getByRole("button", { name: "Sign in" }).click();
 
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(
      page.getByRole("heading", { name: "Dashboard" })
    ).toBeVisible();
  });
});

Run it:

npx playwright test tests/login.spec.ts

What makes this maintainable:

  • Locators use accessible names, not CSS chains like div > form > button:nth-child(2).
  • Assertions use Playwright’s web-first expect, which retries until timeout.
  • The test name states the behavior, matching how you title manual cases.

If your product login is SSO-heavy or MFA-gated, do not force a full UI login in every test. Prefer a storage-state or API login fixture (covered below) and reserve one or two UI login tests for the auth surface itself.

Locators that survive UI churn

Locator choice is the difference between a suite that breaks every sprint and one that only breaks when behavior changes.

Prefer role, label, and text

page.getByRole("button", { name: "Add to cart" });
page.getByLabel("Search products");
page.getByPlaceholder("Order number");
page.getByText("Order confirmed");
page.getByTestId("checkout-submit"); // last resort contract with frontend

getByTestId is fine when the UI has no stable accessible name—icons-only buttons, canvas widgets, or duplicate labels. Agree on a data-testid convention with frontend so tests do not invent attributes ad hoc.

Scope locators

const row = page.getByRole("row", { name: /INV-1042/ });
await row.getByRole("button", { name: "Refund" }).click();

Scoping prevents “clicked the wrong Refund” failures when multiple rows render.

Avoid these patterns

  • XPath that walks the DOM tree for convenience
  • CSS that depends on layout order (nth-child)
  • Text that includes timestamps or user-generated noise
  • Hidden elements that are only in the DOM for analytics

When a locator is ambiguous, Playwright fails loudly. That is a feature. Fix the locator or the accessibility tree; do not paper over ambiguity with force: true clicks.

For larger apps, wrap repeated flows in a thin page object model. Keep page objects as locator and action helpers—not as a second assertion framework.

Assertions and auto-waiting

Playwright auto-waits for actionability before click, fill, and similar actions: attached, visible, stable, enabled, and receiving events. Web-first assertions retry until the condition passes or the timeout expires.

await expect(page.getByRole("alert")).toHaveText("Saved");
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled();
await expect(page.locator(".toast")).toHaveCount(0);

Prefer assertions on outcomes users care about (URL, heading, table row, toast) over intermediate loading spinners—unless the spinner itself is the product requirement.

Soft assertions exist for collecting multiple failures in one test. Use them sparingly. A test that soft-asserts ten unrelated things is usually five tests wearing a trench coat.

Timeouts

Default timeouts are configurable per action, per expect, and globally. Resist raising global timeouts to “make CI green.” That hides real performance regressions and slow selectors. Prefer fixing the wait condition, seeding data faster, or isolating the slow path.

Fixtures: shared setup without globals

Playwright fixtures are typed dependency injection for tests. Use them for authenticated pages, seeded tenants, and API helpers.

import { test as base, expect } from "@playwright/test";
 
type Fixtures = {
  authenticatedPage: import("@playwright/test").Page;
};
 
export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: "playwright/.auth/user.json",
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});
 
export { expect };

Generate storageState once in a setup project:

// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
 
const authFile = "playwright/.auth/user.json";
 
setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.QA_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.QA_USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page).toHaveURL(/\/dashboard/);
  await page.context().storageState({ path: authFile });
});

Wire the setup project in config so dependent projects reuse the state. This pattern keeps auth out of every spec and reduces login rate-limit pain in shared environments.

Test data strategies that reduce flake

Flaky E2E is often flaky data, not flaky Playwright. Common failure modes:

  • Two workers claim the same unique email
  • A previous run left an order in a terminal state
  • Soft-deleted records still appear in search
  • Feature flags differ between local and CI

Practical approaches:

  1. Unique suffixes per run. Append worker index and timestamp to emails and SKUs.
  2. API seeding. Create the entity over HTTP, then open the UI deep link. See also API test automation and API test cases for designing those contracts first.
  3. Cleanup in afterEach or fixture teardown. Delete what you created when the environment allows it.
  4. Immutable reference data. Prefer read-only catalogs for smoke paths when mutation is expensive.

Example of API seed then UI assert:

import { test, expect } from "@playwright/test";
 
test("user opens invoice from list", async ({ page, request }) => {
  const create = await request.post("/api/invoices", {
    data: {
      customerId: "cust_qa_1",
      amountCents: 4200,
      currency: "USD",
    },
  });
  expect(create.ok()).toBeTruthy();
  const { id } = await create.json();
 
  await page.goto("/invoices");
  await page.getByRole("link", { name: id }).click();
  await expect(page.getByRole("heading", { name: id })).toBeVisible();
});

When the API is the source of truth for setup, your UI test focuses on rendering and interaction—not on filling a twelve-field form every time.

Combining API and UI in one journey

A maintainable suite mixes layers:

LayerGood for
Unit / componentPure logic, edge formatting
API / contractAuth rules, validation, persistence
Playwright UINavigation, composition, permissions in the browser

Do not automate every manual login case in Playwright. Automate the high-value paths: successful login, locked account messaging if it is a release risk, and session expiry if your product depends on it. Keep exhaustive field-validation matrices at API or component level when possible.

For story-driven work, draft structured cases from Jira acceptance criteria first, mark which ones are automation candidates, then implement Playwright for the critical subset. QA Workflow Assistant can generate those structured cases from Jira stories so your automation backlog starts from reviewable coverage instead of a blank file.

Traces, screenshots, and debugging

When a test fails on retry with trace: "on-first-retry", open the HTML report:

npx playwright show-report

The trace viewer shows DOM snapshots, network, console, and actions. Use it before adding page.pause() everywhere.

Local debugging tips:

npx playwright test --debug
npx playwright test --ui
PWDEBUG=1 npx playwright test tests/login.spec.ts

For intermittent failures, capture one failing trace and ask: was the element missing, covered, disabled, or did the app return 500? That triage maps cleanly to flaky test root causes—environment, data, timing, or genuinely non-deterministic product behavior.

Running Playwright in CI

A typical GitHub Actions job installs dependencies, installs Chromium, starts the app, waits for health, then runs Playwright. For a fuller pipeline walkthrough, see GitHub Actions for test automation.

Core principles:

  • Install browsers explicitly in CI (npx playwright install --with-deps chromium).
  • Block on readiness, not a fixed sleep, before playwright test.
  • Upload the HTML report and traces on failure as artifacts.
  • Keep a small smoke project that runs on every PR; run deeper suites nightly.
  • Pin worker count to what your shared environment can handle.

Smoke vs deeper regression mirrors the same thinking as smoke testing vs sanity testing: protect the merge gate with a thin, stable path; expand coverage where failure cost is high.

Example fragment:

- name: Install Playwright browsers
  run: npx playwright install --with-deps chromium
 
- name: Run Playwright
  run: npx playwright test --project=chromium
  env:
    BASE_URL: http://localhost:3000
    CI: true
 
- name: Upload report
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report
    path: playwright-report/

Common mistakes that inflate maintenance cost

  1. Testing implementation, not behavior. Asserting CSS class names that designers rename weekly.
  2. One mega-test per epic. Failures become hard to localize; retries become expensive.
  3. Shared mutable accounts across parallel workers. Classic flake factory.
  4. Sleeping instead of waiting for a condition. Use locators and expect; reserve waitForTimeout for rare diagnostics only.
  5. No ownership of selectors. If QA invents data-testid without frontend agreement, drift is guaranteed.
  6. Automating unstable WIP UI. Wait until the flow is demo-stable, or lock behind a feature flag environment.
  7. Ignoring network errors. A green UI assert on cached content can hide API failures—assert critical responses when the risk warrants it.
  8. Skipping manual clarity. If you cannot write a clear manual case, the automated version will be unclear too. Align with how to write QA test cases thinking even when the runner is Playwright.

FAQ

Should every regression case become a Playwright test?

No. Automate stable, high-value journeys and risks that are expensive to check manually. Keep exploratory and highly visual checks human-driven unless you have a dedicated visual pipeline.

Playwright or Cypress?

If you need multiple browser engines, multi-tab flows, or strong API-plus-UI composition in one runner, Playwright is often smoother. If your team is standardized on Cypress and your app fits its model, switching solely for trend reasons is rarely worth it. Read the Cypress guide for the other side of the tradeoff.

How do we stop flakes from blocking releases?

Quarantine with visibility, fix root causes, and avoid silent retries as a strategy. The flaky tests playbook applies whether you use Playwright, Selenium, or Cypress.

Where do page objects fit?

Use them for reuse and readability once duplication hurts—not on day one of a three-test suite. See page object model.

How should stories feed the automation backlog?

Write or generate structured cases from acceptance criteria, tag automation candidates, then implement. Tools like QA Workflow Assistant help turn Jira stories into reviewable case drafts; Docs covers product setup if you want that drafting step in your workflow. Plan limits are on Pricing.

Closing

Playwright rewards teams that treat E2E as a curated suite: clear locators, deterministic data, fixtures for auth, traces for triage, and CI that publishes evidence. Start with one login or checkout path, make it boringly reliable, then expand. Pair the suite with solid case design—and when stories land faster than you can draft coverage, generate structured test cases from Jira with QA Workflow Assistant, review them, and automate only what deserves a browser.

Stay ahead in QA

Get practical QA guides, Jira & Xray tutorials, testing checklists, AI testing insights, and occasional product updates.

No spam. Unsubscribe anytime.