Tutorials/UI Testing with Playwright

UI Testing with Playwright

Frappe's official documentation covers UI testing with Cypress. At Hybrowlabs, we use Playwright instead — it is faster, more reliable, supports multiple browsers natively, has a superior trace viewer, and has become the industry standard for modern web UI testing.

Why Playwright over Cypress? Playwright supports Chromium, Firefox, and WebKit in one tool, runs tests in parallel out of the box, produces video recordings + trace files on failure, has no iframe limitations, and has first-class TypeScript support with better async handling.


Architecture

Playwright UI Testing Flow
Playwright UI Testing Flow — from test spec to CI report with screenshots, videos & trace viewer

Setup

Prerequisites

# Install Node.js (if not already)
node -v  # v18+ recommended

# Install Playwright
npm init playwright@latest

# Install browsers
npx playwright install chromium firefox webkit

Project Structure

your-app/
├── tests/
│   ├── ui/
│   │   ├── helpers/
│   │   │   └── frappe.ts        # login, navigation helpers
│   │   ├── sales-order.spec.ts
│   │   ├── purchase-order.spec.ts
│   │   └── employee.spec.ts
├── playwright.config.ts
└── package.json

playwright.config.ts

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/ui',
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : 2,
  reporter: [['html'], ['list']],
  use: {
    baseURL: 'http://localhost:8000',
    trace: 'on-first-retry',      // capture trace on failure
    video: 'on-first-retry',      // record video on failure
    screenshot: 'only-on-failure',
  },
});

Frappe Login Helper

Create a reusable helper to authenticate with ERPNext before each test:

// tests/ui/helpers/frappe.ts
import { Page } from '@playwright/test';

export async function loginToFrappe(
  page: Page,
  user = 'Administrator',
  password = 'admin'
) {
  await page.goto('/login');
  await page.fill('#login_email', user);
  await page.fill('#login_password', password);
  await page.click('.btn-login');
  await page.waitForURL('**/app**');
}

export async function navigateTo(page: Page, doctype: string, name?: string) {
  const route = name
    ? `/app/${doctype.toLowerCase().replace(/ /g, '-')}/${name}`
    : `/app/${doctype.toLowerCase().replace(/ /g, '-')}/new`;
  await page.goto(route);
  await page.waitForLoadState('networkidle');
}

Writing Tests

Example — Create a Sales Order

// tests/ui/sales-order.spec.ts
import { test, expect } from '@playwright/test';
import { loginToFrappe, navigateTo } from './helpers/frappe';

test.describe('Sales Order', () => {
  test.beforeEach(async ({ page }) => {
    await loginToFrappe(page);
  });

  test('creates a new sales order', async ({ page }) => {
    await navigateTo(page, 'Sales Order');

    // Fill in the form
    await page.fill('[data-fieldname="customer"] input', '_Test Customer');
    await page.keyboard.press('Tab');
    await page.fill('[data-fieldname="delivery_date"] input', '2026-04-30');

    // Add an item row
    await page.click('.btn-open-row');
    await page.fill('[data-fieldname="item_code"] input', '_Test Item');
    await page.fill('[data-fieldname="qty"] input', '10');

    // Save the document
    await page.click('.primary-action');
    await page.waitForSelector('.indicator.green');

    // Assert saved successfully
    await expect(page.locator('.page-title')).not.toContainText('Not Saved');
    await expect(page.locator('.indicator')).toContainText('Saved');
  });

  test('submits a sales order', async ({ page }) => {
    await page.goto('/app/sales-order');
    await page.click('.list-row:first-child a');
    await page.waitForLoadState('networkidle');

    await page.click('[data-label="Submit"]');
    await page.click('.btn-modal-primary');  // confirm dialog

    await expect(page.locator('.indicator.blue')).toContainText('Submitted');
  });

  test('checks permission — restricted user cannot submit', async ({ page }) => {
    await loginToFrappe(page, 'limited_user@example.com', 'password');
    await page.goto('/app/sales-order');
    await page.click('.list-row:first-child a');

    // Submit button should not be visible
    await expect(page.locator('[data-label="Submit"]')).not.toBeVisible();
  });
});

Example — Test a Custom DocType

test('creates a custom document', async ({ page }) => {
  await loginToFrappe(page);
  await navigateTo(page, 'My Custom DocType');

  await page.fill('[data-fieldname="title"] input', 'Test Record');
  await page.selectOption('[data-fieldname="status"] select', 'Active');
  await page.click('.primary-action');

  await expect(page.locator('.indicator')).toContainText('Saved');
});

Running Tests

# Run all UI tests
npx playwright test

# Run a specific spec file
npx playwright test tests/ui/sales-order.spec.ts

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

# Run on a specific browser only
npx playwright test --project=chromium

# Run with 4 parallel workers
npx playwright test --workers=4

# Show HTML report after run
npx playwright show-report

Debugging Failures

Playwright's Trace Viewer is one of its strongest advantages over Cypress — it captures a full timeline of every action, network request, DOM snapshot, and console log.

# Open trace viewer for a failed test
npx playwright show-trace test-results/trace.zip

On failure, Playwright automatically captures: - 📸 Screenshot at the point of failure - 🎥 Video of the full test run - 🔍 Trace file — step-by-step replay with DOM, network, console


Parallel Execution & CI

GitHub Actions

# .github/workflows/ui-tests.yml
name: UI Tests
on: [push, pull_request]

jobs:
  ui-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Frappe Bench
        run: |
          bench init frappe-bench --frappe-branch version-16
          cd frappe-bench
          bench new-site test.local --admin-password admin
          bench install-app erpnext
          bench start &

      - name: Install Playwright
        run: npm ci && npx playwright install --with-deps chromium

      - name: Run UI Tests
        run: npx playwright test --workers=4

      - name: Upload Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

Playwright vs Cypress — Comparison

Feature Playwright Cypress
Browser support Chromium, Firefox, WebKit Chromium only (limited Firefox)
Parallel execution Native, out of the box Requires Cypress Cloud (paid)
Trace viewer Built-in, rich timeline Dashboard only (paid)
Video recording Built-in Built-in
iframe support Full Limited
TypeScript First-class Supported but secondary
Async/await Native Proprietary command queue
Network interception page.route() cy.intercept()
API testing Built-in request fixture Separate plugin
License Apache 2.0 (free) Open core (cloud features paid)
Frappe integration Manual helpers (simple) bench run-ui-tests

At Hybrowlabs, Playwright is the preferred UI testing tool for all ERPNext custom development. It is faster, more capable, and has no paid-tier limitations.

Need help with your workflow setup?

If you're stuck or want help applying these guides to your setup, our team can assist with configuration, customization, and workflow implementation.

WhatsApp