JavaScript August 14, 2026 18 min read

Playwright JavaScript Tutorial 2026: Setup, Write Tests & Run in CI

A complete, hands-on tutorial for using Playwright with plain JavaScript — no TypeScript required. From npm init to cross-browser testing, async/await patterns, Page Object Model, API testing, and CI/CD deployment. Everything you need to start writing reliable end-to-end tests today.

Most Playwright tutorials default to TypeScript. But Playwright works perfectly with plain JavaScript — no tsconfig.json, no type annotations, no compilation step. If you know basic JavaScript, you can start writing production-grade end-to-end tests in minutes.

This tutorial covers everything from setup to CI/CD using plain .js files. If you prefer TypeScript, see our Playwright TypeScript tutorial instead.


Why Playwright + JavaScript?

What you get

  • Zero config — no tsconfig, no build step
  • Cross-browser: Chromium, Firefox, WebKit
  • Auto-waiting on every action
  • Built-in test runner (@playwright/test)
  • Trace Viewer for visual debugging
  • Network interception & mocking
  • Screenshot & video on failure
  • Parallel execution out of the box

Step 1: Create a Playwright Project

One command creates the entire project:

Terminal
# Create a new Playwright project
npm init playwright@latest

# When prompted:
# ✔ Do you want to use TypeScript or JavaScript? → JavaScript
# ✔ Where to put your end-to-end tests? → tests
# ✔ Add a GitHub Actions workflow? → true
# ✔ Install Playwright browsers? → true

This creates:

Project structure
my-playwright-project/
├── tests/
│   └── example.spec.js        // Example test
├── tests-examples/
│   └── demo-todo-app.spec.js  // Full example
├── playwright.config.js       // Configuration
├── package.json
└── .github/
    └── workflows/
        └── playwright.yml     // CI pipeline

Run the example test to verify everything works:

Terminal
# Run all tests
npx playwright test

# Run in headed mode (see the browser)
npx playwright test --headed

# Run with the UI mode (interactive)
npx playwright test --ui

UI mode is the best way to learn Playwright. It shows each test step, DOM snapshots, network requests, and lets you step through tests visually. Try npx playwright test --ui right now.

Step 2: Understand the Config

The playwright.config.js controls browsers, timeouts, reporters, and more:

playwright.config.js
// @ts-check
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  // Directory containing test files
  testDir: './tests',

  // Run tests in parallel
  fullyParallel: true,

  // Fail the build on CI if test.only is left in code
  forbidOnly: !!process.env.CI,

  // Retry failed tests on CI
  retries: process.env.CI ? 2 : 0,

  // Reporter
  reporter: 'html',

  // Shared settings for all tests
  use: {
    // Base URL for page.goto('/')
    baseURL: 'http://localhost:3000',

    // Collect trace on failure
    trace: 'on-first-retry',

    // Screenshot on failure
    screenshot: 'only-on-failure',
  },

  // Browsers to test on
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },

    // Mobile viewports
    { name: 'mobile-chrome',  use: { ...devices['Pixel 5'] } },
    { name: 'mobile-safari',  use: { ...devices['iPhone 13'] } },
  ],
});

Step 3: Write Your First Test

tests/homepage.spec.js
const { test, expect } = require('@playwright/test');

test('homepage has correct title', async ({ page }) => {
  await page.goto('https://playwright.dev/');

  // Assert the page title
  await expect(page).toHaveTitle(/Playwright/);
});

test('get started link works', async ({ page }) => {
  await page.goto('https://playwright.dev/');

  // Click the "Get started" link
  await page.getByRole('link', { name: 'Get started' }).click();

  // Verify we navigated to the install page
  await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
Terminal
# Run this specific file
npx playwright test tests/homepage.spec.js

# Run with verbose output
npx playwright test --reporter=list

Step 4: Understanding async/await in Playwright

If you're new to async/await, this is the one concept you must understand. Every Playwright action talks to a browser over a protocol — that takes time. await tells JavaScript to pause and wait for the action to finish before moving to the next line.

JavaScript — async/await explained
// ✅ CORRECT: Each action waits for the previous one
test('login flow', async ({ page }) => {
  await page.goto('/login');             // Wait: page loads
  await page.getByLabel('Email').fill('a@b.com'); // Wait: text typed
  await page.getByLabel('Password').fill('pass');  // Wait: text typed
  await page.getByRole('button', { name: 'Sign In' }).click(); // Wait: clicked
  await expect(page).toHaveURL('/dashboard'); // Wait: URL changed
});

// ❌ WRONG: Missing await — actions fire simultaneously, test flakes
test('broken test', async ({ page }) => {
  page.goto('/login');            // Fires but doesn't wait!
  page.getByLabel('Email').fill('a@b.com'); // Fires before page loads!
  // 💥 Race condition — sometimes passes, sometimes fails
});

Rule of thumb: Put await before every page. call and every expect() call. If you forget one, the test will be flaky — it might pass locally but fail in CI.

Step 5: Locator Strategies

Locators are how you find elements on the page. Playwright has a clear hierarchy — always prefer the top of this list:

JavaScript — locator strategies (best to worst)
// ✅ BEST: Role-based (accessible, resilient to UI changes)
page.getByRole('button', { name: 'Submit' })
page.getByRole('link', { name: 'Sign In' })
page.getByRole('heading', { name: 'Dashboard' })
page.getByRole('textbox', { name: 'Search' })

// ✅ GOOD: Label and placeholder
page.getByLabel('Email address')
page.getByPlaceholder('Enter your name')

// ✅ GOOD: Test IDs (requires data-testid in HTML)
page.getByTestId('checkout-btn')

// ✅ OK: Text content
page.getByText('Add to Cart')
page.getByText('Add to Cart', { exact: true })

// ⚠️ AVOID: CSS/XPath selectors (brittle)
page.locator('#submit-btn')
page.locator('.form > button:nth-child(2)')
page.locator('xpath=//button[@class="primary"]')

Role-based locators (getByRole) use the same attributes that screen readers use. They almost never break when the UI is redesigned, because accessibility roles stay consistent even when visual styling changes.

Find the right locator fast: Run npx playwright codegen https://your-site.com and click elements. Playwright Codegen generates the best locator for each element automatically.

Step 6: Assertions

Playwright's expect auto-retries until the condition is true (default: 5 seconds). No manual waits needed:

JavaScript — common assertions
const { expect } = require('@playwright/test');

// Page assertions
await expect(page).toHaveURL('https://example.com/dashboard');
await expect(page).toHaveURL(/dashboard/);  // regex
await expect(page).toHaveTitle('Dashboard');

// Element visibility
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByText('Loading')).toBeHidden();

// Element content
await expect(page.getByTestId('count')).toHaveText('42');
await expect(page.getByLabel('Email')).toHaveValue('user@test.com');

// Element state
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('checkbox')).toBeChecked();

// Count elements
await expect(page.getByRole('listitem')).toHaveCount(5);

// Screenshot comparison (visual regression)
await expect(page).toHaveScreenshot('dashboard.png');

Step 7: A Real-World Login Test

tests/login.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Login page', () => {

  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('successful login redirects to dashboard', async ({ page }) => {
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('securePass123');
    await page.getByRole('button', { name: 'Sign In' }).click();

    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  });

  test('invalid credentials show error message', async ({ page }) => {
    await page.getByLabel('Email').fill('wrong@test.com');
    await page.getByLabel('Password').fill('wrongpass');
    await page.getByRole('button', { name: 'Sign In' }).click();

    await expect(page.getByRole('alert')).toHaveText('Invalid email or password');
    await expect(page).toHaveURL('/login');
  });

  test('empty form shows validation errors', async ({ page }) => {
    await page.getByRole('button', { name: 'Sign In' }).click();

    await expect(page.getByText('Email is required')).toBeVisible();
    await expect(page.getByText('Password is required')).toBeVisible();
  });

});

Step 8: Hooks & Test Organization

JavaScript — hooks
const { test, expect } = require('@playwright/test');

// Runs once before all tests in this file
test.beforeAll(async () => {
  console.log('Starting test suite');
});

// Runs before each test
test.beforeEach(async ({ page }) => {
  await page.goto('/');
});

// Runs after each test
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status === 'failed') {
    await page.screenshot({ path: `screenshots/${testInfo.title}.png` });
  }
});

// Group related tests
test.describe('Navigation', () => {
  test('logo links to home', async ({ page }) => { /* ... */ });
  test('footer links work', async ({ page }) => { /* ... */ });
});

Step 9: Page Object Model

For larger test suites, encapsulate page interactions in reusable classes:

pages/LoginPage.js
class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailField = page.getByLabel('Email');
    this.passwordField = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign In' });
    this.errorAlert = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
    return this;
  }

  async login(email, password) {
    await this.emailField.fill(email);
    await this.passwordField.fill(password);
    await this.submitButton.click();
  }
}

module.exports = { LoginPage };
tests/login.spec.js — using POM
const { test, expect } = require('@playwright/test');
const { LoginPage } = require('../pages/LoginPage');

test('successful login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'securePass123');
  await expect(page).toHaveURL('/dashboard');
});

Step 10: Network Interception & Mocking

Intercept API calls to test edge cases without a real backend:

JavaScript — mock API responses
test('shows error when API fails', async ({ page }) => {
  // Mock the API to return a 500 error
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 500,
      body: JSON.stringify({ error: 'Server error' }),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('Something went wrong')).toBeVisible();
});

test('displays user list from API', async ({ page }) => {
  // Mock the API with test data
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(2);
});

Step 11: API Testing (No Browser)

Playwright can test APIs directly without opening a browser:

tests/api.spec.js
const { test, expect } = require('@playwright/test');

test('GET /api/users returns user list', async ({ request }) => {
  const response = await request.get('/api/users');

  expect(response.status()).toBe(200);

  const users = await response.json();
  expect(users.length).toBeGreaterThan(0);
  expect(users[0]).toHaveProperty('name');
});

test('POST /api/users creates a user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Charlie', email: 'charlie@test.com' },
  });

  expect(response.status()).toBe(201);
  const user = await response.json();
  expect(user.name).toBe('Charlie');
});

Step 12: Tracing & Debugging

When a test fails, Playwright's Trace Viewer shows you exactly what happened:

Terminal
# Run with tracing on first retry
npx playwright test --trace on-first-retry

# Run with tracing always on
npx playwright test --trace on

# Open the trace viewer
npx playwright show-trace test-results/login-spec-js-chromium/trace.zip

# Open the HTML report (includes traces)
npx playwright show-report

The Trace Viewer shows a timeline of every action, DOM snapshots before/after each step, network requests, and console logs. It's the fastest way to debug a failing test.

Step 13: CI/CD with GitHub Actions

If you selected "Add a GitHub Actions workflow" during setup, the file is already created. Otherwise:

.github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

JavaScript vs TypeScript for Playwright

Not sure if you should use JS or TS? Here's the honest comparison:

  • Choose JavaScript if: you want zero setup friction, your team already uses JS, or you're a beginner who doesn't want to learn types yet. You can always migrate later.
  • Choose TypeScript if: you want autocomplete in your IDE, catch selector typos before running tests, or your team already uses TS. See our TypeScript tutorial.
  • Features are identical. Every Playwright API works the same in both. The difference is purely about developer experience — not capability.

Migration path: Rename .js files to .ts, add a tsconfig.json, and add types gradually. Playwright's TS support is zero-config — it compiles TypeScript internally, no build step needed.

Generate Tests with AI (Bonus)

Playwright's MCP Server connects Claude AI directly to your running application. Instead of writing tests manually, describe what you want in plain English:

Prompt to Claude
Navigate to http://localhost:3000/checkout and generate
a Playwright JavaScript test that verifies a user can
add an item to the cart, proceed to checkout, and see
the order confirmation.

Claude navigates your live app, reads the real DOM, and generates a complete .spec.js file with accurate locators. See our MCP + Claude AI guide for setup.

Frequently Asked Questions

Can I use Playwright with plain JavaScript?

Yes. Select "JavaScript" when running npm init playwright@latest. All features work identically in JS and TS.

Should I use JavaScript or TypeScript with Playwright?

Both work equally well. JS has zero setup friction. TS adds autocomplete and type safety. Choose based on your team's preference — you can migrate later.

Why does every Playwright test use async/await?

Every action communicates with a browser process asynchronously. await ensures each action completes before the next one starts. Without it, actions fire simultaneously and tests become flaky.

How do I install Playwright for JavaScript?

Run npm init playwright@latest and select JavaScript. This installs everything, downloads browsers, and creates example tests in under 60 seconds.

Is Playwright JavaScript good for beginners?

Yes — it's one of the most beginner-friendly test frameworks. Auto-waiting eliminates timing issues, the API is intuitive, and Trace Viewer provides visual debugging. If you know basic JS, you can start immediately.

Can I use AI to generate Playwright JavaScript tests?

Yes. Playwright's MCP Server connects to Claude AI, which navigates your live app and generates complete .spec.js files from plain English descriptions with accurate, role-based locators.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy InstructorPublished course author
Playwright + AI ExpertSpecialized in AI-powered QA
Production ExperienceEnterprise-grade frameworks
Connect on LinkedIn