Unit tests check pieces. End-to-end (e2e) tests open your real app in a real browser and click through it like a user. They catch problems no unit test can see: a broken route, a missing provider, a form that does not submit. Playwright is a popular tool for this, with auto-waiting and good debugging.
What you will learn
- How to install and configure Playwright for Angular
- How to write stable tests with accessible locators
- How to isolate tests and run them in CI
Setup
In your Angular project:
npm init playwright@latest
Choose a folder named e2e, and say yes to installing browsers. Then edit playwright.config.ts so Playwright starts your app and knows its address:
// File: playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:4200', trace: 'on-first-retry' },
webServer: {
command: 'npm start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
},
});
webServer runs ng serve for you and waits until the URL answers. Run the tests with:
npx playwright test
Tests for the Task Manager
These tests run against the Task Manager project from earlier. We ran them against that app: all five passed.
// File: e2e/tasks.spec.ts
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => localStorage.clear());
await page.reload();
});
async function addTask(page: import('@playwright/test').Page, title: string, priority = 'medium') {
await page.getByLabel('New task').fill(title);
await page.getByLabel('Priority').selectOption(priority);
await page.getByRole('button', { name: 'Add task' }).click();
}
test('shows the page title and an empty list', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Task Manager' })).toBeVisible();
await expect(page.getByText('No tasks to show.')).toBeVisible();
});
test('rejects a title that is too short', async ({ page }) => {
await addTask(page, 'ab');
await expect(page.getByRole('alert')).toContainText('at least 3 characters');
await expect(page.getByText('No tasks to show.')).toBeVisible();
});
test('adds a task, completes it and filters the list', async ({ page }) => {
await addTask(page, 'Write e2e tests', 'high');
await addTask(page, 'Deploy the app');
await expect(page.getByRole('status')).toHaveText('2 open of 2 tasks');
await page.getByRole('checkbox', { name: 'Write e2e tests' }).check();
await expect(page.getByRole('status')).toHaveText('1 open of 2 tasks');
await page.getByRole('button', { name: 'Done', exact: true }).click();
await expect(page.getByRole('listitem')).toHaveCount(1);
await expect(page.getByRole('listitem')).toContainText('Write e2e tests');
});
test('keeps tasks after a reload', async ({ page }) => {
await addTask(page, 'Remember me');
await page.reload();
await expect(page.getByText('Remember me')).toBeVisible();
});
test('deletes a task', async ({ page }) => {
await addTask(page, 'Temporary task');
await page.getByRole('button', { name: 'Delete Temporary task' }).click();
await expect(page.getByText('Temporary task')).toHaveCount(0);
});
Why these tests are stable
- Locators by role and label (
getByRole,getByLabel) match what users and screen readers see. They survive CSS and markup changes, and they quietly check your accessibility: if a control has no label, the locator fails. - Auto-waiting.
await expect(...)retries until the condition is true or it times out. Do not addsleepor fixed waits. - Isolation. Each test gets a fresh browser context. We also clear
localStorageinbeforeEach, so tests do not depend on each other or on order. - Strict mode. If a locator matches more than one element, Playwright stops with an error. In our first run
getByLabel('Write e2e tests')matched both the checkbox and the Delete button (itsaria-labelcontained the same words). UsinggetByRole('checkbox', { name: ... })fixed it, and that is the habit to copy.
Debug
npx playwright test --ui # time-travel UI
npx playwright test --debug # step through
npx playwright show-report # HTML report after a run
npx playwright codegen localhost:4200 # record clicks as test code
Use trace: 'on-first-retry' so failed CI runs come with a trace file you can open and inspect.
Test with a real or fake backend
- For a fast, reliable suite, intercept network calls with
page.route()and return fixed JSON. - Keep a few tests against a real test backend to catch contract mismatches.
- Never run e2e tests against production data, and never put real passwords in test code. Use a seeded test user.
// File: e2e/mocked-api.spec.ts
import { expect, test } from '@playwright/test';
test('shows posts from a mocked API', async ({ page }) => {
await page.route('**/posts', (route) =>
route.fulfill({ json: [{ id: 1, userId: 1, title: 'Mocked post', body: 'Hi' }] }),
);
await page.goto('/posts');
await expect(page.getByText('Mocked post')).toBeVisible();
});
What to cover with e2e tests
Pick the journeys that make or lose money or trust: sign in, search, checkout, create and save. Do not test every detail. A suite of 10 to 30 reliable tests is better than 300 slow, flaky ones.
Try it yourself
- Install Playwright in one of your projects and run the generated example.
- Write a test for your own sign-in flow with a mocked API.
- Run the suite twice in a row. If any test fails the second time, fix its isolation.
Quick check
- Why prefer
getByRoleover CSS selectors? - Why do you not need
sleepin Playwright tests? - How do you keep tests independent?
Next, protect your users in Angular security best practices. See the Playwright documentation.