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:
# 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:
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:
# 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:
// @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
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(); });
# 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.
// ✅ 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:
// ✅ 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:
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
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
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:
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 };
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:
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:
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:
# 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:
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:
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
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.