| name | ng-register-playwright |
| description | Step-by-step guide for implementing Playwright E2E testing in Angular v20+ projects. Covers installation, configuration, test organization with fixtures and page objects, and best practices for modern standalone components with Vitest and Playwright integration. |
Register Playwright for E2E Testing in Angular
Overview
This skill guides coding agents through setting up Playwright E2E testing in Angular v20+ projects. Playwright is a modern browser automation framework supporting Chromium, Firefox, and WebKit. It integrates seamlessly with Angular applications running on ng serve and works alongside Vitest for unit tests.
Key Benefits:
- ✅ Fast Execution – Parallel test runs across multiple browsers
- ✅ Modern API – Async/await native, no callbacks or promises
- ✅ Powerful Tooling – Inspector, trace viewer, and HTML reporter
- ✅ Fixture-Based Testing – Reusable test setup and teardown
- ✅ Page Objects – Encapsulation of UI interactions
- ✅ Network Control – Intercept, mock, and monitor API calls
- ✅ Cross-Browser – Single configuration for Chrome, Firefox, Safari
Target Environment:
- Angular v20.2+
- Node.js 20+
- Vitest (optional but recommended for unit testing alongside E2E)
- JSON Server or similar backend (for API mocking/interaction)
Prerequisites
Before starting, verify:
- Angular project is initialized (
ng version shows v20+)
- Development server works (
ng serve runs without errors)
- Git is initialized (to track changes)
- Backend mock server available (optional but recommended for testing)
Check Angular version:
ng version
Step 1: Install Playwright Dependencies
1a. Install Playwright packages
npm install --save-dev @playwright/test
1b. Install Playwright browsers
After installation, install browser binaries:
npx playwright install
This downloads Chromium, Firefox, and WebKit binaries (~300MB total).
1c. Verify installation
npm list @playwright/test
npx playwright --version
Expected output: @playwright/test@^1.x.x and version number displayed
Step 2: Create Playwright Configuration
Create file at project root: playwright.config.ts
2a. Basic configuration
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
forbidOnly: process.env['CI'] ? true : false,
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'ng serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
},
});
Key Configuration Options:
| Option | Purpose |
|---|
testDir | Directory containing test files (./e2e) |
testMatch | Pattern for test files (**/*.spec.ts) |
fullyParallel | Run all tests in parallel across workers |
retries | Number of retries for failed tests |
reporter | HTML report generation after test runs |
baseURL | Base URL for all navigation (localhost:4200) |
trace | Record trace for debugging (on-first-retry) |
screenshot | Screenshot on failure only |
webServer | Launch dev server automatically before tests |
2b. Multi-browser configuration (optional)
To test across Chromium, Firefox, and WebKit:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
use: {
baseURL: 'http://localhost:4200',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
Step 3: Create E2E Directory Structure
3a. Create e2e folder with test files
mkdir -p e2e
touch e2e/example.spec.ts
3b. Recommended folder structure
e2e/
├── fixtures/
│ ├── example.fixture.ts
│ └── api.fixture.ts
├── pages/
│ └── example.page.ts
├── interactions/
│ └── example.interaction.ts
└── example.spec.ts
Roles:
fixtures/ – Reusable test setup (API reset, page initialization)
pages/ – Page Object Model/Page Helper classes
interactions/ – Interaction scripts (smoke tests, multi-step workflows)
*.spec.ts – Actual test suites
Step 4: Create Fixtures for Test Setup
Fixtures encapsulate test setup and teardown, promoting reusability and reducing boilerplate.
4a. Basic test fixture
Create e2e/example.fixture.ts:
import { test as base, expect, type Page } from '@playwright/test';
export class ExamplePage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/example');
await this.page.waitForLoadState('networkidle');
}
async fillInput(selector: string, value: string) {
await this.page.fill(selector, value);
}
async clickButton(label: string | RegExp) {
await this.page.getByRole('button', { name: label }).click();
}
async expectVisible(text: string | RegExp) {
await expect(this.page.getByText(text)).toBeVisible();
}
async expectHidden(text: string | RegExp) {
await expect(this.page.getByText(text)).not.toBeVisible();
}
}
export const test = base.extend<{ examplePage: ExamplePage }>({
examplePage: async ({ page }, use) => {
const examplePage = new ExamplePage(page);
await examplePage.goto();
await use(examplePage);
},
});
export { expect };
Fixture Pattern:
- Create a Page Object Class (e.g.,
ExamplePage) that wraps Playwright Page
- Define methods for common interactions (goto, fill, click, assertions)
- Use
test.extend() to create a custom test fixture
- Inject a new instance before each test via async factory function
- Export both
test and expect
4b. Fixture with API interaction
For applications that need backend state reset:
Create e2e/api.fixture.ts:
import { test as base, expect, type APIRequestContext, type Page } from '@playwright/test';
const API = 'http://localhost:3000';
const INITIAL_DATA = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];
async function resetData(request: APIRequestContext) {
const existing = await (await request.get(`${API}/data`)).json();
for (const item of existing) {
await request.delete(`${API}/data/${item.id}`);
}
for (const item of INITIAL_DATA) {
await request.post(`${API}/data`, { data: item });
}
}
export class DataPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/data');
await this.page.waitForLoadState('networkidle');
}
listItems() {
return this.page.getByRole('row');
}
addButton() {
return this.page.getByRole('button', { name: /add/i });
}
async expectItemVisible(name: string | RegExp) {
await expect(this.page.getByRole('row', { name })).toBeVisible();
}
}
export const test = base.extend<{ dataPage: DataPage }>({
dataPage: async ({ page, request }, use) => {
await resetData(request);
const dataPage = new DataPage(page);
await dataPage.goto();
await use(dataPage);
},
});
export { expect };
Step 5: Create Page Object Helpers
Page Objects encapsulate UI element selectors and interaction logic, improving test maintainability.
5a. Comprehensive page object example
Create e2e/pages/customers.page.ts:
import { Page, Locator, expect } from '@playwright/test';
export class CustomersPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/customers');
await this.page.waitForLoadState('networkidle');
}
table(): Locator {
return this.page.locator('table');
}
row(name: string | RegExp): Locator {
return this.page.getByRole('row', { name });
}
editButton(name: string | RegExp): Locator {
return this.row(name).getByRole('button', { name: /edit/i });
}
deleteButton(name: string | RegExp): Locator {
return this.row(name).getByRole('button', { name: /delete/i });
}
nameInput(): Locator {
return this.page.getByRole('textbox', { name: /name/i });
}
saveButton(): Locator {
return this.page.getByRole('button', { name: /save/i });
}
cancelButton(): Locator {
return this.page.getByRole('button', { name: /cancel/i });
}
form(): Locator {
return this.page.locator('form');
}
async fillName(value: string) {
await this.nameInput().fill(value);
}
async save() {
await this.saveButton().click();
await this.page.waitForLoadState('networkidle');
}
async cancel() {
await this.cancelButton().click();
}
async expectRowVisible(name: string | RegExp) {
await expect(this.row(name)).toBeVisible();
}
async expectRowHidden(name: string | RegExp) {
await expect(this.row(name)).not.toBeVisible();
}
async expectFormVisible() {
await expect(this.form()).toBeVisible();
}
async expectFormHidden() {
await expect(this.form()).not.toBeVisible();
}
async expectTableContains(count: number) {
const rows = this.page.getByRole('row').filter({ has: this.page.locator('td') });
await expect(rows).toHaveCount(count);
}
}
Page Object Pattern:
- Private field:
private page: Page for encapsulation
- Public element methods: Return
Locator without executing
- Public action methods: Perform interactions (click, fill, navigate)
- Public assertion methods: Validate expected state
- Semantic naming:
editButton(), saveButton() reflect UI intent
- Flexible selectors: Use
getByRole() for accessibility, filter by text/attributes
Step 6: Write Test Suites
Test suites use fixtures and page objects for clean, maintainable tests.
6a. Simple test suite
Create e2e/example.spec.ts:
import { test, expect, ExamplePage } from './example.fixture';
test.describe('Example Feature', () => {
test('displays example page on navigation', async ({ examplePage }) => {
await expect(examplePage.page).toHaveTitle(/Example/);
});
test('shows list of items', async ({ examplePage }) => {
const items = await examplePage.listItems();
expect(items.length).toBeGreaterThan(0);
});
test('can add new item', async ({ examplePage }) => {
await examplePage.clickButton('Add Item');
await examplePage.fillInput('[name="title"]', 'New Item');
await examplePage.clickButton('Save');
await examplePage.expectVisible('New Item');
});
});
6b. Comprehensive test suite with API fixture
Create e2e/customers.spec.ts:
import { test, expect, CustomersPage } from './customers.fixture';
test.describe('Customers — Display', () => {
test('shows all customers on load', async ({ customersPage }) => {
await customersPage.expectRowVisible('Cleo');
await customersPage.expectRowVisible('Soi');
await customersPage.expectRowVisible('Giro');
});
test('table has correct headers', async ({ customersPage }) => {
const table = customersPage.table();
await expect(table.getByRole('columnheader', { name: 'Name' })).toBeVisible();
await expect(table.getByRole('columnheader', { name: 'Actions' })).toBeVisible();
});
});
test.describe('Customers — Edit', () => {
test('opens edit form when edit button clicked', async ({ customersPage }) => {
await customersPage.editButton(/Cleo/).click();
await customersPage.expectFormVisible();
await expect(customersPage.nameInput()).toHaveValue('Cleo');
});
test('updates row after save', async ({ customersPage }) => {
await customersPage.editButton(/Cleo/).click();
await customersPage.fillName('Cleo Updated');
await customersPage.save();
await customersPage.expectRowVisible('Cleo Updated');
await customersPage.expectRowHidden('Cleo');
await customersPage.expectFormHidden();
});
test('discards changes on cancel', async ({ customersPage }) => {
await customersPage.editButton(/Soi/).click();
await customersPage.fillName('Soi Changed');
await customersPage.cancel();
await customersPage.expectFormHidden();
await customersPage.expectRowVisible('Soi');
await customersPage.expectRowHidden('Soi Changed');
});
});
test.describe('Customers — Delete', () => {
test('removes customer from table', async ({ customersPage }) => {
await customersPage.expectRowVisible('Giro');
await customersPage.deleteButton(/Giro/).click();
await customersPage.page.waitForLoadState('networkidle');
await customersPage.expectRowHidden('Giro');
});
test('updates row count after delete', async ({ customersPage }) => {
await customersPage.expectTableContains(4);
await customersPage.deleteButton(/Flora/).click();
await customersPage.page.waitForLoadState('networkidle');
await customersPage.expectTableContains(3);
});
});
test.describe('Customers — Error Handling', () => {
test('shows error message on network failure', async ({ customersPage, page }) => {
await page.route('**/api/customers/**', route => route.abort());
await customersPage.editButton(/Cleo/).click();
await customersPage.fillName('Cleo Error Test');
await customersPage.save();
await expect(page.getByText(/error|failed/i)).toBeVisible();
});
});
Step 7: Create Interaction Scripts (Optional)
Interaction scripts are not test assertions but sequential step-by-step walkthroughs useful for smoke testing or documentation.
Create e2e/customers.interaction.ts:
import { chromium } from '@playwright/test';
(async () => {
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
try {
await page.goto('http://localhost:4200/customers');
await page.waitForLoadState('networkidle');
console.log('✔ Customers page loaded');
const cleoRow = page.getByRole('row', { name: /Cleo/ });
await cleoRow.getByRole('button').first().click();
await page.getByRole('textbox', { name: 'Name' }).fill('Cleo Updated');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
console.log('✔ Edited Cleo → Cleo Updated');
const updatedRow = page.getByRole('row', { name: /Cleo Updated/ });
await updatedRow.getByRole('button').last().click();
await page.waitForLoadState('networkidle');
console.log('✔ Deleted Cleo Updated');
await page.getByRole('button', { name: /add/i }).click();
await page.getByRole('textbox', { name: 'Name' }).fill('New Customer');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
console.log('✔ Added new customer');
console.log('\n✅ All interactions completed successfully');
} catch (error) {
console.error('❌ Interaction failed:', error);
} finally {
await browser.close();
}
})();
Run interaction script in headed mode:
npx playwright test e2e/customers.interaction.ts --headed
Step 8: Configure npm Scripts
Update package.json with E2E test commands:
{
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:debug": "playwright test --debug",
"e2e:headed": "playwright test --headed",
"e2e:chromium": "playwright test --project=chromium",
"e2e:firefox": "playwright test --project=firefox",
"e2e:webkit": "playwright test --project=webkit"
}
}
Command Reference:
| Command | Purpose |
|---|
npm run e2e | Run all tests in headless mode |
npm run e2e:ui | Interactive UI mode (pause, slow-mo, inspect) |
npm run e2e:debug | Debug mode with inspector |
npm run e2e:headed | Run in visible browser windows |
npm run e2e:chromium | Run on Chromium only |
Step 9: Run Tests and Validate
9a. Start development server (if not using webServer)
In one terminal:
ng serve
9b. Run E2E tests in new terminal
npm run e2e
9c. Expected output
Running 12 tests using 1 worker
...
✓ e2e/customers.spec.ts:10:3 › Customers — Display › shows all customers on load (2.1s)
✓ e2e/customers.spec.ts:15:3 › Customers — Display › table has correct headers (1.8s)
✓ e2e/customers.spec.ts:21:3 › Customers — Edit › opens edit form (1.9s)
...
12 passed (8.4s)
To open the last HTML report run: npx playwright show-report
9d. View HTML report
npx playwright show-report
Opens interactive report with video recordings, traces, and screenshots.
9e. Run in UI mode (recommended for development)
npm run e2e:ui
Launches Playwright Inspector with controls for step-through debugging.
Step 10: Advanced Configuration
10a. Network interception and mocking
Mock API responses without needing backend:
test('handles API error gracefully', async ({ page, customersPage }) => {
await page.route('/api/customers', route => {
route.abort('failed');
});
await customersPage.goto();
await expect(page.getByText(/error|no data/i)).toBeVisible();
});
test('displays mock data', async ({ page, customersPage }) => {
await page.route('/api/customers', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Mock Customer' },
]),
});
});
await customersPage.goto();
await customersPage.expectRowVisible(/Mock Customer/);
});
10b. Authentication fixture
For protected routes:
import { test as base } from '@playwright/test';
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
await use(page);
},
});
10c. Custom reporters
Generate reports in different formats:
export default defineConfig({
reporter: [
['html'],
['json', { outputFile: 'test-results.json' }],
['junit', { outputFile: 'test-results.xml' }],
['list'],
],
});
Step 11: CI/CD Integration
11a. GitHub Actions example
Create .github/workflows/e2e.yml:
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
11b. GitLab CI example
Create .gitlab-ci.yml:
e2e-tests:
image: mcr.microsoft.com/playwright:v1-focal
script:
- npm ci
- npm run build
- npm run e2e
artifacts:
when: always
paths:
- playwright-report/
expire_in: 30 days
Best Practices
✅ Dos
- Use fixtures for setup/teardown (API reset, authentication, page initialization)
- Use Page Objects to encapsulate selectors and interactions
- Use semantic locators (
getByRole, getByLabel) for accessibility
- Wait for network with
waitForLoadState('networkidle')
- Assertions separate from actions – one assertion per test or related assertions
- Meaningful test names – describe behavior, not implementation
- Screenshot/trace on failure – configured in playwright.config.ts
- Parallel tests – Playwright runs tests concurrently by default
- CI retries – Set 1-2 retries in CI to handle flaky tests
test('updates customer name and persists to server', async ({ customersPage }) => {
await customersPage.editButton(/Alice/).click();
await customersPage.fillName('Alice Updated');
await customersPage.save();
await customersPage.expectRowVisible('Alice Updated');
await customersPage.expectRowHidden('Alice');
});
❌ Avoids
- Hard waits – Use
waitForLoadState(), waitForSelector(), or expect with timeout
- Sleeping –
setTimeout() in tests makes them slow and flaky
- Implementation details – Test behavior, not internal state
- Multiple test responsibilities – One test = one scenario
- Overlapping test state – Use fixtures for isolation
- Hardcoded delays – Use proper wait strategies
test('updates customer', async ({ page }) => {
await page.goto('/customers');
await page.waitForTimeout(500);
await page.click('button:has-text("Edit")');
await page.waitForTimeout(300);
await page.fill('input#name', 'New Name');
await page.click('button:has-text("Save")');
});
🎯 Naming Conventions
Test Suites:
test.describe('Feature — Responsibility', () => {
test.describe('Display', () => { });
test.describe('Interaction', () => { });
test.describe('Error Handling', () => { });
});
Page Objects:
export class CustomerPage {
nameInput(): Locator { }
saveButton(): Locator { }
async fillName(value: string) { }
async save() { }
async expectNameToBe(value: string) { }
}
Troubleshooting Common Issues
Issue 1: Tests Timeout
Symptom: Timeout 30000ms exceeded
Solution:
- Check dev server is running:
ng serve in separate terminal
- Increase timeout in config:
timeout: 60000 (in config or per test)
- Use explicit waits:
await page.waitForLoadState('networkidle')
- Check
baseURL matches dev server: http://localhost:4200
test('slow operation', async ({ customersPage }) => {
test.setTimeout(60000);
await customersPage.goto();
});
Issue 2: Selector Not Found
Symptom: Locator.click: Target page, context or browser has been closed
Solution:
- Use
--headed mode to debug: npm run e2e:headed
- Use
--debug mode with inspector: npm run e2e:debug
- Use semantic locators (role-based):
await page.click('.btn');
await page.getByRole('button', { name: /save/i }).click();
Issue 3: Tests Pass Locally, Fail in CI
Symptom: Local green, CI red
Solution:
- Run in headed mode to verify visually
- Add retries in CI config (not locally):
export default defineConfig({
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
});
- Use traces and screenshots for debugging:
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
}
Issue 4: Flaky Tests (Intermittent Failures)
Symptom: Same test sometimes passes, sometimes fails
Solution:
- Use
waitForLoadState() instead of hard waits
- Wait for specific elements before assertions:
await page.click('button');
await expect(page.getByText('Updated')).toBeVisible();
await page.click('button');
await page.waitForLoadState('networkidle');
await expect(page.getByText('Updated')).toBeVisible();
- Use explicit waits for async operations:
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForResponse(response =>
response.url().includes('/api/') && response.status() === 200
);
Issue 5: Authentication Issues
Symptom: Can't access protected routes
Solution:
Use auth fixture to perform login once:
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('input[type="email"]', 'test@example.com');
await page.fill('input[type="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await use(page);
},
});
Integration with Angular CLI MCP
Use Angular CLI MCP tools alongside Playwright:
Discover project structure:
→ Identify project name and root
→ Note workspace path for other MCP commands
Get best practices before writing tests:
→ Ensure tests align with current Angular standards
→ Verify OnPush and signal patterns in tested components
Search for testing patterns:
→ Find TestBed patterns
→ Look up component testing with signals
→ Reference fixture and page object examples
Verification Checklist
References
Example Project Structure
demo-project/
├── src/
│ ├── app/
│ │ ├── customers/
│ │ │ └── customers.component.ts
│ │ └── app.component.ts
│ ├── main.ts
│ └── index.html
├── e2e/
│ ├── fixtures/
│ │ ├── api.fixture.ts
│ │ └── auth.fixture.ts
│ ├── pages/
│ │ ├── customers.page.ts
│ │ └── login.page.ts
│ ├── customers.spec.ts
│ ├── customers.interaction.ts
│ └── login.spec.ts
├── angular.json
├── playwright.config.ts
├── package.json
├── tsconfig.json
└── README.md
Quick Start Summary
- Install:
npm install --save-dev @playwright/test && npx playwright install
- Configure: Create
playwright.config.ts with testDir: './e2e'
- Create Fixture: Write
e2e/example.fixture.ts with Page Object class
- Write Tests: Create
e2e/example.spec.ts using fixture
- Run Tests:
npm run e2e (dev server auto-starts if webServer configured)
- View Report:
npx playwright show-report
For detailed examples, see the vitetest-testing demo at demos/08-testing/vitetest-testing/.