Angular E2E with Playwright: Locators, Auto-Waiting, Network Mocking, storageState and the Trace Viewer
To E2E test an Angular UI without a backend, wire Playwright to ng e2e, fake the API with an automatic page.route fixture and treat every unanswered request as a failure; otherwise the development server returns index.html. On Bookstore's search, cart and checkout flow we show role-based locators, signing in once with storageState, traces and sources of flaky tests, all with real runs.
Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Playwright 1.65 (@playwright/test 1.65.0) and playwright-ng-schematics 22.1.4 on Node.js 24.
Guess First
We are writing the first E2E tests for the Angular UI of Bookstore, the online bookstore used as the shared example across this site's series. There is no backend; the tests fake the API with Playwright's page.route. The checkout tests use an api fixture written for this. The new test for the catalog sorting, however, forgot to ask for the fixture as a parameter:
import { expect, test } from './fixtures';
test('sorts the catalog by price with the Material select', async ({ page }) => {
await page.goto('/');
await page.getByRole('combobox', { name: 'Sort by' }).click();
await page.getByRole('option', { name: 'Price: low to high' }).click();
await expect(page.getByRole('article').getByRole('heading')).toHaveText(['The Hobbit', 'Dune']);
});
The tests run with ng e2e; Angular's development server (ng serve) serves the app. There is no mock answering the /api/books request and no API on port 4200. What does the page show, and how does the test fail? Does the request fail with "connection refused"?
Answer: The Development Server Returned index.html
The test waited 5 seconds and failed:
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('article').getByRole('heading')
Timeout: 5000ms
- Expected - 4
+ Received + 1
- Array [
- "The Hobbit",
- "Dune",
- ]
+ Array []
The error-context.md file Playwright writes to the test folder gives the page's accessibility tree at that moment, and the real cause is visible there:
- complementary "Cart status" [ref=e10]: "Cart: 0 books · Free"
- heading "Catalog" [level=1] [ref=e11]
- searchbox "Search books" [ref=e12]
- combobox "Sort by" [active] [ref=e18]:
- generic [ref=e19]: "Price: low to high"
- alert [ref=e27]: Could not load the catalog.
- button "Try again" [ref=e28]
The request did not fail. To leave every unknown path to the Angular router, the development server returns index.html, and /api/books is one of those paths. When I printed the response to the same request with page.waitForResponse, it was 200 text/html <!doctype html>. httpResource got a 200, expected JSON, saw HTML, went into the error state, and the page said "Could not load the catalog." An unmocked request silently turns into a wrong answer this way. Worse, a test that checks the error state could pass for the wrong reason.
The fix has two parts: set up the mock automatically in every test, and count every unanswered request as a failure at the end of the test. It is the Playwright counterpart of what we did for MSW with onUnhandledFrame: 'error' in post 5:
export const test = base.extend<{ api: MockApi }>({
api: [
async ({ page }, use) => {
const api = new MockApi();
await page.route('**/api/**', (route) => api.handle(route));
await use(api);
expect(api.unhandled, 'API requests without a mock').toEqual([]);
},
{ auto: true },
],
});
{ auto: true } runs the fixture in every test, even when the test does not ask for it as a parameter. The line after use() runs after the test finishes. In the rest of this post we build Bookstore's "search for a book, add it to the cart, check out" flow on top of this fixture.
Playwright itself, meaning locators, auto-wait, web-first assertions and the trace viewer, is covered in the Integration and E2E Testing series post "Getting Started with Playwright". Full-stack E2E with the real API and SQL Server, test data and real sign-in with a setup project are in post 7 of that series; flaky tests, page.clock and visual comparison are in its post 8. In this post there is no backend: we open the built Angular app in a real browser, fake the API and test only the UI flow. Such a test is fast and deterministic, but it does not show that the API and the UI fit together. The two complement each other.
Setup with ng e2e: playwright-ng-schematics
The Angular CLI no longer ships with an E2E tool; when there is no e2e target, ng e2e suggests the community package playwright-ng-schematics for Playwright. We use the package in this series because we want to run the tests as a target of the Angular project:
npx ng add playwright-ng-schematics@22.1.4
Adding @playwright/test 1.65.1
CREATE playwright.config.ts (1933 bytes)
CREATE e2e/example.spec.ts (208 bytes)
CREATE e2e/tsconfig.json (64 bytes)
UPDATE angular.json (3176 bytes)
UPDATE package.json (1479 bytes)
UPDATE .gitignore (760 bytes)
This is the target added to angular.json:
"e2e": {
"builder": "playwright-ng-schematics:playwright",
"options": {
"devServerTarget": "bookstore-web:serve"
},
"configurations": {
"production": {
"devServerTarget": "bookstore-web:serve:production"
}
}
}
The builder first starts the devServerTarget, that is ng serve, then runs playwright test, passing the server address through the PLAYWRIGHT_TEST_BASE_URL environment variable, and stops the server when the tests finish. That is why the webServer setting from the Integration and E2E Testing series is not needed. ng e2e --configuration production runs the tests against the production build (9 tests in 4.5 seconds in Bookstore); that is what you should prefer in CI, because it is the code that reaches users.
The schematic added the latest version of @playwright/test. We pinned it to 1.65.0: the playwright package used by browser mode in post 7 is also 1.65.0, and each Playwright version wants its own browser build. If the two packages are on different versions, you have to download two separate sets of browsers. The browsers are installed once with npx playwright install chromium webkit.
We simplified playwright.config.ts for Bookstore:
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: process.env['CI'] ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: process.env['PLAYWRIGHT_TEST_BASE_URL'] ?? 'http://localhost:4200',
trace: 'on-first-retry',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: signedInState },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: signedInState },
dependencies: ['setup'],
},
],
});
The builder has its own options; Playwright's --grep did not pass through directly (Error: Unknown argument: grep). To run a single file in a single project we used npx ng e2e --files e2e/checkout.spec.ts --test-project chromium; since the name --project is reserved for the Angular project, the option for the Playwright project is --test-project.
Faking the API with page.route
MockApi is a small fake of the Bookstore API: the book list and search, ratings, sign-in, placing an order and order details. Each test gets its own instance, so tests do not see each other's data:
export class MockApi {
books: Book[] = [dune, emma, hobbit];
readonly placedOrders: PlaceOrderRequest[] = [];
readonly unhandled: string[] = [];
failOrders = false;
async handle(route: Route): Promise<void> {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
if (method === 'GET' && url.pathname === '/api/books') {
const search = url.searchParams.get('search')?.toLowerCase() ?? '';
return route.fulfill({ json: this.books.filter((book) => book.title.toLowerCase().includes(search)) });
}
if (method === 'POST' && url.pathname === '/api/orders') {
if (this.failOrders) {
return route.fulfill({ status: 503, json: { title: 'Service Unavailable', status: 503 } });
}
this.placedOrders.push(request.postDataJSON() as PlaceOrderRequest);
return route.fulfill({ status: 201, json: this.lastOrder() });
}
// rating, sign-in and order detail work the same way
this.unhandled.push(`${method} ${url.pathname}${url.search}`);
return route.fulfill({ status: 404, json: { title: 'Not Found', status: 404 } });
}
}
The types (Book, PlaceOrderRequest) and the book data are imported from the files the app and the unit tests use; Playwright compiles the TypeScript files itself. A model change in the app shows up as a compile error in the mock. placedOrders lets the test check what was sent to the API; failOrders reduces the failure scenario to one line. When page.route is legitimate, and in which cases it should be used in a suite that runs against a real backend, is discussed in the Integration and E2E Testing series post on flaky tests; here there is no backend, so the whole API is fake.
Testing the Flow: Role-Based Locators
The main flow is Bookstore's most valuable path: find a book, add it to the cart, place the order:
test('a customer finds a book, adds it to the cart and places an order', async ({ page, api }) => {
await page.goto('/');
await page.getByRole('searchbox', { name: 'Search books' }).fill('hobbit');
const results = page.getByRole('list', { name: 'Books' }).getByRole('article');
await expect(results).toHaveCount(1);
await results.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByRole('link', { name: 'Cart (1)' })).toBeVisible();
await page.getByRole('link', { name: 'Cart (1)' }).click();
await page.getByRole('link', { name: 'Checkout' }).click();
await page.getByLabel('Full name').fill('Ada Lovelace');
await page.getByLabel('Email').fill('ada@example.com');
await page.getByLabel('City').selectOption('Izmir');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page).toHaveURL('/orders/42');
await expect(page.getByRole('heading', { name: 'Order BS-1042' })).toBeVisible();
await expect(page.getByRole('list', { name: 'Order lines' })).toHaveText('1 × The Hobbit');
expect(api.placedOrders).toEqual([
{ fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir', lines: [{ isbn: '9780261103344', quantity: 1 }] },
]);
});
The search box uses the 300 ms debounced() from post 2, but there is no wait in the test. toHaveCount(1) keeps retrying until the list is down to one card; even if the timing changes, the test does not. The locators use the same roles and names as the Testing Library queries in post 4: searchbox, list "Books", link "Cart (1)". Because we fixed the accessible names in the component tests, the E2E test did not need data-testid.
The same goes for Angular Material. In post 6 we used mat-select through a harness; in Playwright there is no need for a harness, because the Material select exposes the right ARIA roles: in its closed state it is combobox "Sort by", and the options in the panel are option. Playwright's actionability check also waits for the panel's opening animation; the click happens when the element is visible, stable and can receive the click. Once the fixture was automatic, the test from the start of this post passed in both browsers.
Signing In Once: storageState
The checkout and order pages are behind authGuard; the session lives in the Session service, under the bookstore.session key in localStorage. Instead of filling in the sign-in form in every test, we sign in once in a setup project and write the browser state to a file:
setup('sign in once', async ({ page, api }) => {
await page.goto('/sign-in');
await page.getByLabel('Email').fill('ada@example.com');
await page.getByLabel('Password').fill('not-checked-by-the-mock');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/');
await page.context().storageState({ path: signedInState });
});
signedInState is playwright/.auth/customer.json. In the config, the chromium and webkit projects run this step first with dependencies: ['setup'] and open a signed-in browser context for every test with storageState: signedInState. According to the Playwright documentation, storageState saves cookies and localStorage; IndexedDB is optional. sessionStorage is not saved. If Bookstore kept the session in sessionStorage, the tests would start signed out and the guard would send each of them to /sign-in; for this case the documentation suggests restoring it by hand with addInitScript. Because the file carries session data, the playwright/.auth/ folder is in .gitignore.
The file that tests signed-out behavior clears the state:
test.use({ storageState: { cookies: [], origins: [] } });
test('a signed-out visitor is sent to sign in before checkout', async ({ page }) => {
await page.goto('/');
await page.getByRole('article').filter({ hasText: 'Dune' }).getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart (1)' }).click();
await page.getByRole('link', { name: 'Checkout' }).click();
await expect(page).toHaveURL('/sign-in?returnUrl=%2Fcheckout');
});
Including the setup, the 9 tests passed in 3.7 seconds with 5 workers; the whole ng e2e command, including the development server build, took 5.2 seconds.
Reading a Red Test and Sources of Flakiness
trace: 'on-first-retry' in the config does not collect a trace on the first attempt; it collects one when the test fails and is retried. That way passing tests do not slow down, and you get a recording of the failing one. When I deliberately broke the expected text of the order line and ran with --retries 1, the output was:
✘ 3 [chromium] › e2e/checkout.spec.ts:3:1 › a customer finds a book, adds it to the cart and places an order (6.3s)
✘ 4 [chromium] › e2e/checkout.spec.ts:3:1 › a customer finds a book, adds it to the cart and places an order (retry #1) (6.4s)
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('list', { name: 'Order lines' })
Expected: "2 × The Hobbit"
Received: "1 × The Hobbit"
Timeout: 5000ms
attachment #2: trace (application/zip) ──────────────────────────────
test-results/checkout-a-customer-finds--b0964-he-cart-and-places-an-order-chromium-retry1/trace.zip
Usage:
npx playwright show-trace test-results/checkout-a-customer-finds--b0964-he-cart-and-places-an-order-chromium-retry1/trace.zip
The trace viewer has a DOM snapshot of every step, the network requests and the console; how to read it is covered in the Integration and E2E Testing series post "Getting Started with Playwright". Two tips that help on the Angular side: requests answered by page.route show up as "fulfilled" in the network tab, which tells you they never reached a real server; and the bug is often in the step before, so work backwards from the failing assertion.
The general causes of flaky tests and their fixes are in post 8 of the Integration and E2E Testing series. In a mocked Angular UI we often see four sources:
- Order dependency. When tests rely on state left by another test, they fail when they run in parallel or in a different order. Each test gets its own
MockApiinstance and its own browser context;fullyParallel: trueputs this independence to the test on every run. - Animations. Material panels and dialogs move while they open and close. Playwright waits for the element to be stable before clicking; do not add a fixed
waitForTimeout. If needed, usereducedMotion: 'reduce'on the context. - Time and dates. If texts such as the order date, the end of a campaign or "ships within 3 days" depend on the day the test runs, the test breaks one day. Keep the dates in the mock fixed; use
page.clockto freeze the browser's clock. - Shared test data. Here the data is created from scratch in every test; in a suite running against a real backend this is the biggest problem, and it is the subject of the Integration and E2E Testing series.
When Does Cypress Still Make Sense?
Cypress is at version 16.3 on npm and can be wired to ng e2e for Angular with @cypress/schematic. It runs tests inside the browser; commands are added to a queue, and you do not write async/await. Its interactive runner, where you can rewind and watch the app step by step, is still a strong point. Why we chose Playwright for Bookstore: WebKit support (experimental in Cypress through experimentalWebKitSupport), the ability to work with multiple tabs and browser contexts, a Promise-based API that fits TypeScript naturally, and the same browser infrastructure as Vitest browser mode in post 7. If the team has a large Cypress suite and no need for WebKit, migrating just to switch tools is not worth it; for a new project, starting with Playwright makes more sense.
In Production
Keep E2E tests few and valuable. In Bookstore, E2E only covers flows that need several pages and real browser behavior together: search → cart → checkout, checkout while signed out, keeping the cart when an order fails, and the Material select. Form validation, price calculation and the details of the guard are tested much faster in component and routed feature tests.
In CI, test the production build with ng e2e --configuration production, and turn on retries and a single worker with the CI variable. Installing browsers, caching them and uploading the HTML report as an artifact is the subject of the GitHub Actions series post "Angular CI with GitHub Actions".
The mock drifting away from the API is a risk here too. Because the mock's types come from the app's types, field name changes are caught at compile time; but the API's real behavior (sorting, error codes) only shows up with a real backend. A version of the main flow that runs against the real API is in the Integration and E2E Testing series.
Common Pitfalls
Jumping between pages with page.goto. In one attempt, after adding a book to the cart, I went to checkout with page.goto('/checkout'). goto reloads the page from scratch; because CartStore keeps the cart in memory, the cart was emptied and the order reached the API with lines: []:
Error: expect(received).toEqual(expected) // deep equality
- Expected - 6
+ Received + 1
- Array [
- Object {
- "isbn": "9780441013593",
- "quantity": 1,
- },
- ]
+ Array []
Click links as the user does; use goto only at the start of a flow. This attempt showed one more thing: the checkout page allows placing an order with an empty cart.
Forgetting the mock and not noticing the development server's index.html answer. That is the situation at the start of this post: without the auto fixture and the unhandled check, an unmocked request is answered with a 200.
Assuming storageState also saves sessionStorage. It does not; if the session is in sessionStorage, you need addInitScript.
Committing the auth file. The file in playwright/.auth/ carries session data; if it was produced against a real environment, it is enough to impersonate an account.
A Small Experiment
★☆☆ Add the category filter (?category=classics) to MockApi and test that the list narrows when a category is selected. Run the test before adding the filter: what does the unhandled check report?
★★☆ Block checkout with an empty cart: when the cart is empty, the checkout page should show "Your cart is empty." instead of the form. Write the component test first, then an E2E test with page.goto('/checkout'). At which level is it enough to test this behavior?
★★★ Remove the setup project and provide the session directly with test.use({ storageState: { cookies: [], origins: [{ origin, localStorage: [...] }] } }). In which case is this approach enough, and in which case do you need to test the sign-in form itself?
With this post, the Angular 22 test tools of today are complete: Vitest, Testing Library, harnesses, browser mode and Playwright. In the last post of the series we look back: migrating a suite written with Karma and Jasmine to Vitest, and what changed in testing from Angular 12 to 22.