Angular 104 🅰️ E2E Testing with Playwright
Playwright is a modern end-to-end testing framework built by Microsoft. It runs tests in real browsers — Chromium, Firefox, and WebKit — and it is designed around a set of principles that make tests reliable: auto-waiting, web-first assertions, and test isolation. Angular has embraced Playwright as a first-class E2E testing solution through a set of official and community schematics that integrate Playwright with the Angular CLI.
The previous chapters covered unit tests with Jasmine and Karma. E2E tests are different. A unit test verifies a component or a service in isolation. An E2E test verifies the entire application — the routing, the HTTP calls, the forms, the rendering — in a real browser. It is the test that tells you whether a user can actually accomplish a task, not just whether a function returns the right value.
Key point: Playwright’s core design principle is auto-waiting. Every action and every assertion waits for the element to be ready before proceeding. Playwright’s documentation states that web-first assertions like expect(locator).toHaveText() automatically retry until the assertion passes or the timeout expires. This is different from expect(await locator.textContent()), which takes a snapshot and does not retry . Auto-waiting is what makes Playwright tests less flaky than traditional E2E frameworks.
Why Playwright for Angular
Angular applications are single-page applications. The page does not reload on navigation. The DOM updates asynchronously. E2E tests must wait for the application to stabilize before asserting. Playwright’s auto-waiting handles this without the manual waitFor calls that older frameworks require.
The flakiness problem. Traditional E2E tests fail intermittently because they assert before the application has finished rendering. Playwright’s web-first assertions retry until the assertion passes or the timeout expires. A test that would fail once in ten runs with a snapshot assertion passes consistently with a web-first assertion.
The browser problem. Angular applications must work across Chromium, Firefox, and WebKit. Playwright runs tests in all three browsers from a single configuration. The --project flag selects which browser to run. The CI pipeline runs all three in parallel .
The integration problem. The Angular CLI has an e2e target. Schematics like playwright-ng-schematics and @ngx-playwright/test configure the e2e target to run Playwright. The developer runs ng e2e and the tests execute through the Angular CLI, with the development server started automatically .
The isolation problem. Playwright runs each test in a fresh browser context. The context has its own cookies, local storage, and session storage. A test cannot leak state into another test. The isolation is automatic .
The trade-off. Playwright tests are slower than unit tests because they run a real browser. The test suite must be organized so that the fast, critical tests run first and the slow, comprehensive tests run later. Playwright’s --workers flag runs tests in parallel to reduce the total time.
a. Setting Up Playwright in an Angular Project
The recommended way to add Playwright to an Angular project is through a schematic. Two schematics are widely used: playwright-ng-schematics and @ngx-playwright/test.
The playwright-ng-schematics schematic installs Playwright Test, configures the ng e2e command, and optionally sets up component testing .
ng add playwright-ng-schematics
The schematic adds a playwright builder to the e2e target in angular.json. The developer runs npm run e2e or ng e2e to execute the tests. The schematic also adds an example test file and a playwright.config.ts file.
The @ngx-playwright/test package provides additional Angular-specific fixtures: automatic stabilization, component harness support, and the inScreen helper .
ng add @ngx-playwright/test
The manual installation is also possible:
npm install -D @playwright/test
npx playwright install
The npx playwright install command downloads the browser binaries for Chromium, Firefox, and WebKit. The binaries are managed by Playwright and do not depend on the browsers installed on the developer’s machine .
The playwright.config.ts file configures the test environment:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:4200',
},
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});
The webServer option starts the Angular development server before the tests run and shuts it down after. The baseURL option lets the tests use relative URLs like page.goto('/') instead of the full address .
b. Writing Playwright Tests
A Playwright test is a function that receives a page object and interacts with the application through it.
import { test, expect } from '@playwright/test';
test('user can navigate to the dashboard', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Login' }).click();
await page.getByLabel('Username').fill('testuser');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
The getByRole locator finds elements by their ARIA role and accessible name. It is the recommended locator strategy because it matches how assistive technology perceives the page . The getByLabel locator finds form controls by their associated label. The getByText locator finds elements by their text content.
The expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() assertion is a web-first assertion. It retries until the heading is visible or the timeout expires .
c. Testing Angular-Specific Patterns
Playwright tests verify Angular behaviors through the rendered DOM. Angular’s signals and observables are not directly accessible from Playwright — the test runs in a separate process. The correct approach is to verify the rendered output, not the internal state .
Testing router navigation. The page.waitForURL method waits for the URL to change after a navigation. The expect(page).toHaveURL() assertion checks the current URL.
test('route guard redirects unauthorized users', async ({ page }) => {
await page.goto('/admin/settings');
await expect(page).toHaveURL(/\/login/);
});
Testing lazy-loaded modules. The page.waitForResponse method waits for a network request. The test registers the listener before triggering the navigation .
const chunkRequest = page.waitForResponse(
(r) => r.url().includes('.js') && r.status() === 200
);
await page.getByRole('link', { name: 'Analytics' }).click();
await chunkRequest;
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
Testing Angular Material components. The Angular Material components have well-defined ARIA roles. The mat-dialog has role="dialog". The mat-table has role="table". The mat-sort-header has aria-sort. Playwright can locate and assert these roles .
test('mat-dialog interaction', async ({ page }) => {
await page.goto('/members');
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Confirm deletion?')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
Testing signals through the UI. Playwright cannot read a signal directly. The test verifies the signal’s effect on the DOM. When the signal updates, the DOM updates, and the assertion checks the DOM .
test('signal updates the count in the template', async ({ page }) => {
await page.goto('/counter');
await expect(page.getByText('Count: 0')).toBeVisible();
await page.getByRole('button', { name: '+' }).click();
await expect(page.getByText('Count: 1')).toBeVisible();
});
Complete Example Session
This session demonstrates Playwright tests for an Angular application with routing, forms, and Material components.
// ============================================
// PART 1: THE PLAYWRIGHT CONFIG
// ============================================
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});
The configuration runs the tests in Chromium, Firefox, and WebKit. The trace option captures a trace on the first retry for debugging. The webServer option starts the Angular development server automatically.
// ============================================
// PART 2: THE APP ROUTES
// ============================================
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './auth.guard';
export const routes: Routes = [
{ path: '', loadComponent: () => import('./home.component').then(m => m.HomeComponent) },
{ path: 'login', loadComponent: () => import('./login.component').then(m => m.LoginComponent) },
{ path: 'dashboard', loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent), canActivate: [authGuard] },
{ path: 'members', loadComponent: () => import('./members.component').then(m => m.MembersComponent) },
];
// ============================================
// PART 3: THE E2E TEST — LOGIN FLOW
// ============================================
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can log in and reach the dashboard', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Login' }).click();
await expect(page).toHaveURL(/\/login/);
await page.getByLabel('Username').fill('testuser');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('route guard redirects unauthorized users', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
});
// ============================================
// PART 4: THE E2E TEST — MATERIAL COMPONENTS
// ============================================
// e2e/members.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Member Management', () => {
test('mat-table sorting', async ({ page }) => {
await page.goto('/members');
const nameHeader = page.getByRole('columnheader', { name: 'Name' });
await nameHeader.click();
await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
await nameHeader.click();
await expect(nameHeader).toHaveAttribute('aria-sort', 'descending');
});
test('mat-paginator navigation', async ({ page }) => {
await page.goto('/members');
await expect(page.getByText('1 - 10 of 100')).toBeVisible();
await page.getByRole('button', { name: 'Next page' }).click();
await expect(page.getByText('11 - 20 of 100')).toBeVisible();
});
test('mat-dialog confirmation', async ({ page }) => {
await page.goto('/members');
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Confirm deletion?')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
});
// ============================================
// PART 5: THE E2E TEST — SIGNALS THROUGH UI
// ============================================
// e2e/counter.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Counter with Signals', () => {
test('increments the count in the template', async ({ page }) => {
await page.goto('/counter');
await expect(page.getByText('Count: 0')).toBeVisible();
await expect(page.getByText('Double: 0')).toBeVisible();
await page.getByRole('button', { name: '+' }).click();
await expect(page.getByText('Count: 1')).toBeVisible();
await expect(page.getByText('Double: 2')).toBeVisible();
});
});
// ============================================
// PART 6: THE E2E TEST — LAZY LOADING
// ============================================
// e2e/lazy-loading.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Lazy Loading', () => {
test('lazy-loaded module loads without chunk errors', async ({ page }) => {
const chunkErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error' && msg.text().includes('ChunkLoadError')) {
chunkErrors.push(msg.text());
}
});
await page.goto('/');
const chunkRequest = page.waitForResponse(
(r) => r.url().includes('.js') && r.status() === 200
);
await page.getByRole('link', { name: 'Analytics' }).click();
await chunkRequest;
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
expect(chunkErrors).toEqual([]);
});
});
// ============================================
// PART 7: THE E2E TEST — FORM VALIDATION
// ============================================
// e2e/forms.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Form Validation', () => {
test('shows error for empty required field', async ({ page }) => {
await page.goto('/contact');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Name is required')).toBeVisible();
});
test('shows error for invalid email', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Email').fill('not-an-email');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Invalid email format')).toBeVisible();
});
test('submits successfully with valid data', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Name').fill('Alice');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Thank you')).toBeVisible();
});
});
// ============================================
// PART 8: THE CI PIPELINE
// ============================================
// .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
The CI pipeline installs the Playwright browsers with the system dependencies, runs the E2E tests, and uploads the test report on failure.
// ============================================
// PART 9: THE PAGE OBJECT MODEL
// ============================================
// e2e/pages/login.page.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Submit' });
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('testuser', 'password');
await expect(page).toHaveURL(/\/dashboard/);
});
The page object model encapsulates the locators and the actions for a page. The test uses the page object instead of the raw locators. When the page changes, the page object changes, and the tests remain unchanged .
// ============================================
// PART 10: THE E2E CHECKLIST
// ============================================
// 1. Install Playwright with the Angular schematic
// 2. Configure playwright.config.ts with baseURL and webServer
// 3. Use getByRole for locators
// 4. Use web-first assertions for retry
// 5. Test the critical user workflows
// 6. Test the router navigation and guards
// 7. Test the Material components through their ARIA roles
// 8. Test the signals through the rendered DOM
// 9. Use the page object model for maintainability
// 10. Run the tests in CI across all browsers
The ten parts cover the Playwright config, the app routes, the login flow test, the Material components test, the signals test, the lazy loading test, the form validation test, the CI pipeline, the page object model, and the E2E checklist.
Quick Reference
The Playwright Locators
| Locator | Purpose |
|---|---|
getByRole(role, options) | Find by ARIA role |
getByLabel(text) | Find form control by label |
getByText(text) | Find by text content |
getByPlaceholder(text) | Find by placeholder |
getByTestId(id) | Find by test ID |
locator(css) | Find by CSS selector |
The Web-First Assertions
| Assertion | Purpose |
|---|---|
toBeVisible() | Element is visible |
toBeHidden() | Element is hidden |
toHaveText(text) | Element has text |
toContainText(text) | Element contains text |
toHaveValue(value) | Input has value |
toHaveURL(url) | Page URL matches |
toHaveAttribute(name, value) | Element has attribute |
The Waiting Methods
| Method | Purpose |
|---|---|
waitForURL(url) | Wait for URL change |
waitForResponse(pattern) | Wait for network response |
waitForSelector(selector) | Wait for element |
waitForLoadState(state) | Wait for page load state |
The Angular CLI Commands
| Command | Purpose |
|---|---|
ng e2e | Run E2E tests |
ng e2e --ui | Run with Playwright UI |
ng e2e --headed | Run with visible browser |
ng e2e --project=chromium | Run a specific browser |
ng e2e --files e2e/auth.spec.ts | Run a specific file |
Best Practices
✅ Do This:
// Use getByRole for locators
page.getByRole('button', { name: 'Submit' }) // ✅
// Use web-first assertions
await expect(page.getByRole('heading')).toBeVisible() // ✅
// Use waitForResponse for lazy-loaded chunks
const req = page.waitForResponse(r => r.url().includes('.js')) // ✅
// Use page objects for maintainability
const loginPage = new LoginPage(page) // ✅
❌ Don’t Do This:
// Don't use CSS selectors when roles work
page.locator('.btn-primary') // ❌
// Don't use snapshot assertions that don't retry
expect(await page.locator('h1').textContent()).toBe('Dashboard') // ❌
// Don't use page.waitForTimeout
await page.waitForTimeout(5000) // ❌
// Don't test internal state
// Playwright cannot read signals or observables directly. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Test flaky | Snapshot assertion used | Use web-first assertion |
| Element not found | Wrong locator | Use getByRole |
| Timeout waiting for chunk | Missing waitForResponse | Register listener before click |
| Browser not installed | npx playwright install not run | Run the install command |
| CI fails on missing deps | --with-deps not used | Add --with-deps to install |
| Test leaks state | No context isolation | Playwright isolates by default |
Real-World Examples
1. Login Flow
await page.getByLabel('Username').fill('testuser');
await page.getByRole('button', { name: 'Submit' }).click();
2. Router Navigation
await page.getByRole('link', { name: 'Dashboard' }).click();
await expect(page).toHaveURL(/\/dashboard/);
3. Route Guard
await page.goto('/admin');
await expect(page).toHaveURL(/\/login/);
4. Material Dialog
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
5. Material Table Sort
await page.getByRole('columnheader', { name: 'Name' }).click();
await expect(header).toHaveAttribute('aria-sort', 'ascending');
6. Signals Through UI
await page.getByRole('button', { name: '+' }).click();
await expect(page.getByText('Count: 1')).toBeVisible();
7. Lazy Loading
const req = page.waitForResponse(r => r.url().includes('.js'));
await page.getByRole('link', { name: 'Analytics' }).click();
await req;
8. Form Validation
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Name is required')).toBeVisible();
9. Page Object Model
const loginPage = new LoginPage(page);
await loginPage.login('user', 'pass');
10. CI Pipeline
- run: npx playwright install --with-deps
- run: npm run e2e
Visual
The Playwright Test Flow
┌──────────────────────────────────────────────┐
│ PLAYWRIGHT TEST FLOW │
│ │
│ 1. Launch the browser │
│ 2. Create a fresh context │
│ 3. Navigate to the page │
│ 4. Locate the element │
│ 5. Auto-wait for readiness │
│ 6. Perform the action │
│ 7. Web-first assertion retries │
│ 8. Assertion passes or times out │
│ │
│ The context is destroyed after the test. │
│ │
└──────────────────────────────────────────────┘
The Auto-Waiting
┌──────────────────────────────────────────────┐
│ AUTO-WAITING │
│ │
│ Without auto-wait: │
│ click() → element not ready → fail │
│ │
│ With auto-wait: │
│ click() → wait for ready → click → pass │
│ │
│ Playwright checks: │
│ ├─ Element is attached │
│ ├─ Element is visible │
│ ├─ Element is stable │
│ └─ Element receives events │
│ │
└──────────────────────────────────────────────┘
The Web-First Assertions
┌──────────────────────────────────────────────┐
│ WEB-FIRST ASSERTIONS │
│ │
│ expect(locator).toBeVisible() │
│ └─ Retries until visible or timeout │
│ │
│ expect(await locator.isVisible()).toBe(true)│
│ └─ Snapshot, no retry │
│ │
│ The first form is preferred. │
│ It handles async rendering automatically. │
│ │
└──────────────────────────────────────────────┘
The Page Object Model
┌──────────────────────────────────────────────┐
│ PAGE OBJECT MODEL │
│ │
│ LoginPage │
│ ├─ usernameInput: Locator │
│ ├─ passwordInput: Locator │
│ ├─ submitButton: Locator │
│ ├─ goto(): void │
│ └─ login(user, pass): void │
│ │
│ Test │
│ ├─ const page = new LoginPage(page) │
│ ├─ await page.goto() │
│ └─ await page.login('user', 'pass') │
│ │
│ The locators are in one place. │
│ The tests are readable. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Playwright | E2E testing framework by Microsoft |
| Browsers | Chromium, Firefox, WebKit |
| Auto-waiting | Waits for elements before acting |
| Web-first assertions | Retry until pass or timeout |
| Locators | getByRole, getByLabel, getByText |
| Angular CLI | ng e2e via schematic |
| Config | playwright.config.ts |
| Page objects | Encapsulate locators and actions |
| Signals | Verified through the DOM |
| Lazy loading | waitForResponse for chunks |
Key takeaways:
- Playwright is a modern E2E testing framework that runs tests in real browsers. It supports Chromium, Firefox, and WebKit from a single configuration. The Angular CLI integrates Playwright through schematics like
playwright-ng-schematicsand@ngx-playwright/test. - Auto-waiting is Playwright’s core design principle. Every action and every assertion waits for the element to be ready before proceeding. Web-first assertions like
expect(locator).toHaveText()automatically retry until the assertion passes or the timeout expires . - Locators should be based on roles, not CSS classes. The
getByRolelocator finds elements by their ARIA role and accessible name. It matches how assistive technology perceives the page and is resilient to DOM changes . - Angular-specific patterns are tested through the rendered DOM. Playwright cannot read signals or observables directly. The test verifies the signal’s effect on the DOM. The router navigation is verified with
waitForURLandtoHaveURL. The lazy-loaded chunks are verified withwaitForResponse. - Angular Material components have well-defined ARIA roles. The
mat-dialoghasrole="dialog". Themat-tablehasrole="table". Themat-sort-headerhasaria-sort. Playwright can locate and assert these roles . - The page object model encapsulates locators and actions. The page object is a class that represents a page or a component. The test uses the page object instead of the raw locators. When the page changes, the page object changes, and the tests remain unchanged .
- The CI pipeline runs the E2E tests across all browsers. The
npx playwright install --with-depscommand installs the browser binaries and their system dependencies. Thenpm run e2ecommand runs the tests. The test report is uploaded on failure.
Remember: Playwright is the modern E2E testing framework for Angular applications. It runs in real browsers, auto-waits for elements, and uses web-first assertions that retry. The Angular CLI integrates it through schematics. The tests verify the application through the rendered DOM, not the internal state. The locators are based on roles. The page objects encapsulate the locators and the actions. The CI pipeline runs the tests across all browsers. Playwright is the tool that verifies the entire application works for the user.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!