Playwright end-to-end testing in 60 minutes: cross-browser auth flow
Install Playwright, record a login flow with codegen, factor out a page object, save auth state, run cross-browser in parallel, ship to GitHub Actions.
The bottom line
This tutorial gets a cross-browser Playwright suite running end-to-end in about 60 minutes: install the test runner and browser binaries, record a login flow with npx playwright codegen, refactor the recorded script into a page object, save authenticated state to a JSON file so other tests reuse it, run the suite in parallel across Chromium, Firefox, and WebKit, and wire the whole thing into GitHub Actions. The current Playwright version is 1.60.0, released 11 May 2026. 11 Node.js 20 or newer is the supported floor. 14 Skip this if you already have a Playwright suite running with storageState-based auth and a CI pipeline; come back when you need to add a second auth role or split workers across machines.
Prerequisites
A Node.js 20+ install (the current Playwright requirements list 20, 22, or 24 LTS 14 ), a terminal, and a target web application with a login form. The examples assume a TypeScript project; JavaScript works with minor syntax changes. No prior Playwright experience required.
Step 1: Install Playwright
The npm init playwright@latest command bootstraps a fresh test project or adds Playwright to an existing one. Per the install docs, 1 it prompts for TypeScript vs JavaScript, a tests folder name, optional GitHub Actions workflow scaffolding, and browser binary install.
mkdir playwright-tutorial && cd playwright-tutorial
npm init playwright@latest
Answer the prompts: TypeScript, tests/ folder, GitHub Actions workflow yes, install browsers yes. The browser-install step downloads Chromium, Firefox, and WebKit binaries into a Playwright-managed cache (typically several hundred MB total). Per the browsers doc, 5 Playwright pins specific browser builds rather than using whatever’s installed on the machine, which is what makes cross-browser results reproducible.
Verify the install:
npx playwright --version
As of 2026-05-19 npm lists 1.60.0 as the current stable. 12 Run the scaffolded example test:
npx playwright test
The output reports passed tests for each of the three browser projects. If only one browser ran, check playwright.config.ts: the projects array should list chromium, firefox, and webkit blocks.
Image: Playwright v1.60.0 release on GitHub, used for editorial coverage of the current release line.
Step 2: Record a login flow with codegen
Codegen opens a Playwright-controlled browser, watches your interactions, and emits a working test script. Per the codegen docs, 2 the invocation is npx playwright codegen <URL>.
npx playwright codegen https://demo.playwright.dev/todomvc
Two windows appear: the controlled browser and an inspector showing the generated code. Interactions become script lines. Click a form field, type, submit, and the inspector accumulates:
await page.goto('https://demo.playwright.dev/todomvc/');
await page.getByPlaceholder('What needs to be done?').click();
await page.getByPlaceholder('What needs to be done?').fill('buy milk');
await page.getByPlaceholder('What needs to be done?').press('Enter');
await expect(page.getByTestId('todo-title')).toHaveText('buy milk');
Codegen prefers role-based and accessibility-based locators (getByRole, getByLabel, getByPlaceholder, getByTestId) over CSS selectors. This matters: per the locators guidance in the codegen docs, 2 CSS and XPath selectors break when the markup changes; role-based locators survive most refactors because they bind to the accessibility tree.
For a login flow, point codegen at the login page and walk through the form:
npx playwright codegen https://your-app.example.com/login
Fill the email and password fields, click submit, wait for the dashboard. Copy the generated code into tests/auth.spec.ts:
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('https://your-app.example.com/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/.*\/dashboard/);
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
Run it across all three browsers:
npx playwright test auth.spec.ts
Step 3: Refactor into a page object
A page object encapsulates the locators and actions for a single page. Per the page object models doc, 6 the pattern lets the test file describe what happens at the user level while the page object owns the how of locator selection.
Create tests/pages/LoginPage.ts:
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await expect(this.page).toHaveURL(/.*\/dashboard/);
}
}
Update the test:
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'correct-horse-battery-staple');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
Add a baseURL to playwright.config.ts so page.goto('/login') resolves correctly:
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'https://your-app.example.com',
trace: 'on-first-retry',
},
// ...rest of config
});
The trace: 'on-first-retry' setting records a full trace on retry attempts, which is the input to Playwright’s Trace Viewer. 13 When a test fails in CI, the trace lets you scrub through the run frame by frame.
Step 4: Save authenticated state with storageState
Re-logging in before every test is slow and exercises the login flow rather than the feature under test. Per the auth doc, 3 the canonical pattern is to run a one-time setup that logs in, save the resulting browser context (cookies, localStorage) to a JSON file, and load that state into every subsequent test.
Create tests/auth.setup.ts:
import { test as setup, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
const authFile = 'playwright/.auth/user.json';
setup('authenticate as user', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(
process.env.TEST_USER_EMAIL!,
process.env.TEST_USER_PASSWORD!,
);
// confirm dashboard rendered before saving
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
Add a setup project and a dependency on it in playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://your-app.example.com',
trace: 'on-first-retry',
},
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
The dependencies: ['setup'] array tells Playwright to run the setup project once before any browser project starts. Per the auth doc, 3 the setup project executes once per test run and produces the storageState file; the three browser projects all read from it.
Add playwright/.auth/ to .gitignore. The state file contains session cookies and must never land in source control.
Now write a test that uses the authenticated state:
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
test('dashboard loads with user data', async ({ page }) => {
await page.goto('/dashboard');
// no login step needed; storageState already authenticated
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
For multi-role suites (admin user vs regular user vs read-only user), add additional setup files (admin.setup.ts, readonly.setup.ts), produce separate storageState files, and create per-role projects in the config. The auth doc 3 walks through the multi-role variant in full.
Image: microsoft/playwright-mcp on GitHub, used for editorial coverage of the broader Playwright tooling ecosystem.
Step 5: Custom fixtures wrap the page object
Per the test-fixtures doc, 4 Playwright fixtures let you extend the test object with custom typed properties. The standard upgrade from a hand-instantiated page object is a fixture that constructs the page object and injects it into every test.
Create tests/fixtures.ts:
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
type Fixtures = {
loginPage: LoginPage;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
export { expect } from '@playwright/test';
Now tests import from ./fixtures instead of @playwright/test:
import { test, expect } from './fixtures';
test('login flow', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('user@example.com', 'correct-horse-battery-staple');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
The fixture composition mirrors the storageState pattern: setup outputs become fixture inputs, fixtures encapsulate per-test construction, and the test file reads as a sequence of user-level actions.
Step 6: Parallelism across browsers and files
Playwright runs tests in parallel by default. Per the parallelism doc, 7 the default is one worker process per CPU core, with each worker running tests sequentially within itself. Files run in parallel; tests within a file run sequentially in the same worker.
Common overrides in playwright.config.ts:
export default defineConfig({
// override default workers (default: half of CPU cores in CI, all cores locally)
workers: process.env.CI ? 4 : undefined,
// run tests within a file in parallel (default: sequential)
fullyParallel: true,
// ...
});
The fullyParallel: true flag 7 tells the runner to parallelise tests within a single file across multiple workers, not just files across workers. This is fastest but requires that tests within a file don’t depend on each other’s side effects. Per the parallelism doc, the safer default is fullyParallel: false until tests are confirmed independent.
Test isolation: each test gets a fresh BrowserContext (cookies, localStorage, session storage all reset). This is what makes parallel runs deterministic even when many tests log in as the same user. The auth setup project is the one exception: it shares the resulting storageState file across all dependent projects.
To shard a suite across multiple machines (a CI matrix), use --shard:
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
Each shard runs a quarter of the tests; the four shards run in parallel on separate runners. The parallelism doc 7 covers the shard semantics and the JSON-reporter merge step for combining results.
Step 7: Ship to GitHub Actions
The npm init playwright@latest step generates a workflow file at .github/workflows/playwright.yml. The CI doc 8 covers what the scaffolded version does; a minimal cross-browser matrix looks like:
name: playwright tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
env:
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
run: npx playwright test
- name: Upload test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Two CI-specific configuration items. First, npx playwright install --with-deps installs system dependencies (fonts, codecs, X server bits) needed for Chromium and WebKit on a clean Ubuntu runner; per the CI doc, 8 the --with-deps flag is the recommended path on GitHub-hosted runners. Second, the if: ${{ !cancelled() }} condition uploads the HTML report even when tests fail, which is the common debugging path.
For sharded CI runs, add a matrix strategy:
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
# ...
- run: npx playwright test --shard=${{ matrix.shard }}
The shards run in parallel; combine the resulting JSON reports in a follow-up job if a single unified HTML report matters to the team.
Image: GitHub Actions setup-node repository, used for editorial coverage of the CI Node.js-setup integration.
Common gotchas
A short list of the failure modes that bite first-time Playwright users.
Storage state expires. Session cookies and JWTs have lifetimes; the storageState file produced by auth.setup.ts is valid only as long as the session it captures. For long-running CI suites or short session lifetimes, run the setup project at the start of every CI invocation (default behaviour) rather than caching the file. Per the auth doc, 3 the recommendation is to regenerate per CI run, not per developer-machine session.
Codegen output uses brittle selectors when the app lacks semantics. If the target app’s login form lacks <label> elements, aria-label attributes, or data-testid hooks, codegen falls back to CSS class names and structural selectors. Per the codegen doc, 2 the workaround is to add accessibility attributes to the app before recording; the test code that results survives refactors.
fullyParallel: true exposes hidden test coupling. Per the parallelism doc, 7 the safe sequence is to start with fullyParallel: false and flip the flag once tests are confirmed independent. A test that mutates shared state (uploads a file with a fixed name, asserts a global counter value) will fail non-deterministically under full parallelism.
Browser binaries drift between developer machines and CI. Playwright pins specific browser builds per release. 5 If a developer’s local install is months behind the CI install, tests can pass locally and fail in CI on selector-resolution differences. The fix is to keep Playwright version-pinned in package.json and run npx playwright install after every dependency update.
WebKit on Linux differs from Safari on macOS. Playwright’s WebKit binary on Linux is a build from the WebKit source tree; per the browsers doc, 5 it’s representative of Safari behaviour but not identical. Bugs that reproduce only on macOS Safari may not surface in the Linux WebKit CI run. For high-stakes Safari coverage, supplement Linux WebKit with a macOS runner.
Tests that pass once but flake under retries. If a test passes on retry but failed first, the trace recorded at trace: 'on-first-retry' is the diagnostic. Open it with npx playwright show-trace trace.zip and step through the timeline. 13 Flake usually traces to one of: implicit waits replaced by explicit selectors, network mocks that race against real network calls, or animations that haven’t settled before assertions.
Image: microsoft/playwright-python on GitHub, used for editorial coverage of the Python bindings as an alternative to the TypeScript flow shown above.
Where to go next
Three Playwright surfaces are worth knowing about beyond this tutorial. The Trace Viewer 13 is the post-mortem tool: it records a snapshot per action with DOM, network, console, and screenshots, and lets you scrub the timeline to find the frame where a test went wrong. The 1.60.0 release 11 exposed HAR recording as a first-class tracing API, which is useful for inspecting network behaviour separately from the full trace.
API testing is built in: await page.request.post(...) issues HTTP requests through Playwright’s request context, which inherits the same auth cookies as the browser context. This lets you mix browser-driven and API-driven steps in a single test (log in via UI, set up state via API, assert UI rendering).
Component testing is supported for React, Vue, and Svelte via @playwright/experimental-ct-react and equivalents. The setup is more involved than the default end-to-end mode; the release notes 9 track its evolution from experimental to stable.
For long-term reference, the release notes 9 publish breaking changes and migration notes for every minor version. Pin Playwright in package.json and review the release notes before bumping; the framework moves fast enough that selector or fixture behaviour can shift between versions.
If your team writes in Python rather than TypeScript, Playwright ships a first-party Python binding at the microsoft/playwright-python repository. The locator API, fixtures, and storageState pattern map across one-for-one; the runner integration differs (Playwright for Python integrates with pytest rather than its own runner), and the configuration surface uses pytest.ini / pyproject.toml instead of playwright.config.ts. For mixed-stack teams, the choice usually tracks which language the rest of the test infrastructure already lives in.
One operational note on the codegen-to-page-object workflow: codegen records the assertions you click “Add assertion” for inside the inspector, but it does not infer assertions from page behaviour. A recorded login flow with no assertions added will produce a test that drives the form but verifies nothing. The pattern that scales is to record the user journey, then read through the generated code adding await expect(...).toBeVisible() / .toHaveText() / .toHaveURL() checks at the points where the test should fail loudly if the app behaviour regresses.
How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.
Sources consulted
Cited Sources
- 1. Playwright documentation — Installation; bootstrap command, prompt flow, browser binary install. (accessed ) ↩
- 2. Playwright documentation — Codegen test generator; recording flow and locator-preference rules. (accessed ) ↩
- 3. Playwright documentation — Authentication; storageState pattern, setup project, multi-role variant. (accessed ) ↩
- 4. Playwright documentation — Test fixtures; extending the test object with custom typed properties. (accessed ) ↩
- 5. Playwright documentation — Browsers; pinned browser builds, install commands, Linux WebKit caveat. (accessed ) ↩
- 6. Playwright documentation — Page object models. (accessed ) ↩
- 7. Playwright documentation — Parallelism; worker semantics, fullyParallel flag, shard usage. (accessed ) ↩
- 8. Playwright documentation — Continuous integration; GitHub Actions setup, --with-deps install flag. (accessed ) ↩
- 9. Playwright documentation — Release notes. (accessed ) ↩
- 10. Playwright source repository on GitHub. (accessed ) ↩
- 11. Playwright v1.60.0 release on GitHub; released 11 May 2026, HAR tracing API and drag-and-drop additions. (accessed ) ↩
- 12. Playwright on npm — current published version. (accessed ) ↩
- 13. Playwright documentation — Trace Viewer; post-mortem timeline tool, trace.zip inspection. (accessed ) ↩
- 14. Playwright documentation — System requirements; Node.js 20, 22, 24 LTS support. (accessed ) ↩
Anonymous · no cookies set