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

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.