A QA automation engineer's notes on why Angular Material makes Playwright tests pass while the app saves the wrong thing.
I maintain a Playwright suite for a B2B order-management platform built in Angular with Angular Material. Over a few months of running it against a live environment, I collected a catalog of failures that all share one ugly property: the test goes green while the application quietly does the wrong thing. Material's abstractions are great for users and hostile to naive automation, and every trap below comes back to the same root: the DOM you can see is not the state the form will submit.
Here's the catalog: symptom, root cause, fix. Every fix ends up encapsulated in a page object exactly once, so specs never deal with overlay containers or pristine-state tricks directly.
The big one: fill() saves nothing, and the test passes anyway
A spec edits the website field on an order, clicks Save, the save succeeds, the test passes. Weeks later someone notices the value in the database never changed.
The cause is locator.fill(). It sets the input's DOM value directly. Angular's ReactiveForms track their own control state, and after a fill() the control can stay ng-pristine. On submit, the form builds its payload from dirty controls only. The field the test "filled" is omitted from the PATCH as "user didn't touch this", the backend happily saves the other fields, and returns 200. It gets worse: expect(input).toHaveValue(x) passes immediately after the fill, because the DOM does hold the value. Every layer is telling the truth except the one that matters.
The fix has two parts. First, commit changes with real keystrokes and a blur, so Angular's value-accessor and touched/dirty machinery actually fire:
// orderFormPage.ts — commit a value the form will actually submit
async setWebsite(value: string): Promise<void> {
await this.websiteInput.click();
await this.websiteInput.clear();
await this.websiteInput.pressSequentially(value, { delay: 10 }); // real keystrokes
await this.websiteInput.press('Tab'); // blur → ng-touched, control goes dirty
}
Second, and this is the part that kills the bug class forever: never verify a save by reading the input back. The input is the thing that lied to you. Re-read the persisted state through a side door, an API GET or a hard page reload.
await orderForm.setWebsite(sentinel);
await orderForm.clickSave();
const saved = await api.getOrder(orderId); // ask the backend, not the input
expect(saved.website).toBe(sentinel);
One nuance: "needs keystrokes" is a property of the test, not the field. A field you type into only to assert a max-length and then discard can stay on fill(), because it's never read at save time. Any field the spec actually submits takes the keystroke path.
Phantom dropdowns: mat-select and the CDK overlay
You click a mat-select, then getByRole('option', ...) times out. Or you scope the option locator inside the form component and it finds nothing, ever.
There are two separate betrayals here. The options panel doesn't render inside the select. It mounts in a CDK overlay container attached near document.body, completely outside the component subtree your other locators are scoped to. And under headless Chromium, the click sometimes focuses the trigger without rendering the panel at all, most often on the second or third select after a previous panel just closed and its backdrop is still tearing down.
The fix is to locate options globally by role, treat the open as something that can fail, and verify the pick registered:
// matSelect.ts — the one canonical open-and-pick
export async function pickMatOption(page: Page, trigger: Locator, name: string) {
// Options live in the global overlay, NOT under the form component.
const option = page.getByRole('option', { name, exact: true });
for (let attempt = 0; attempt < 3; attempt += 1) {
await trigger.click();
if (await option.waitFor({ state: 'attached', timeout: 3_000 })
.then(() => true).catch(() => false)) break;
await trigger.press('Enter'); // keyboard fallback when the click is swallowed
if (await option.waitFor({ state: 'attached', timeout: 1_000 })
.then(() => true).catch(() => false)) break;
}
await option.click();
// The option detaching is the signal the pick registered and the panel closed.
await option.waitFor({ state: 'hidden', timeout: 10_000 });
}
Three details earn their keep here. exact: true matters because option lists love substring collisions: "Standard" also matches "Standard Plus", and a substring match silently picks the wrong entry. Multi-selects don't auto-close, so press Escape after picking. And inside dialogs, the select's transparent backdrop can outlive the panel and intercept your next click, so wait for it to detach before moving on.
Asserting on the wrong element
expect(select).toHaveValue('Approved') fails even though the dropdown clearly shows "Approved". Or the inverse: toContainText on an autocomplete input passes when it shouldn't.
A mat-select is not an <input>. The displayed text lives in a value span inside the trigger, and there's no value attribute to read. A Material autocomplete, on the other hand, is a real <input role="combobox">, where textContent is always empty, so toContainText on it is vacuously wrong in the other direction.
Match the assertion to the element. Trigger text for selects, value for inputs:
await expect(orderForm.statusSelect).toContainText('Approved'); // mat-select trigger
await expect(orderForm.customerInput).toHaveValue(/Acme/); // real <input>
Never toContainText on an <input>. It reads an empty string and the assertion can pass without testing anything.
Labels are decoration, accessible names are real
getByLabel('Status') finds nothing, or getByText('Status:') fails even though the label is right there on screen.
Material wraps labels in mat-form-field machinery with floating-label states, and visual decorations like the trailing colon are often rendered via CSS ::after, so they're not in the text content at all. The required asterisk is aria-hidden, so it's invisible to text matching too.
Prefer getByRole with the accessible name. The accessibility tree is computed correctly even when the visual DOM is layers of wrappers:
this.statusSelect = page.getByRole('combobox', { name: 'Status' });
this.websiteInput = page.getByRole('textbox', { name: 'Website' });
Same logic for "is this field required": assert aria-required="true", not the asterisk.
"Did the save finish?" when the URL never changes
View mode and edit mode share the same route, so there's nothing to waitForURL after Save. The lazy fix, waitForTimeout(2000), passes locally and flakes everywhere else.
Wait for something concrete instead: the network response (page.waitForResponse on the PUT) or a UI signal that only appears on completion. The base form POM races the success signal against the error banner, so a rejected save fails in seconds with a real message instead of a 30-second hang:
async clickSave(): Promise<void> {
await this.saveButton.click();
const saved = this.editButton.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => 'saved' as const).catch(() => 'timeout' as const);
const failed = this.page.getByText('Save failed')
.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => 'failed' as const).catch(() => 'timeout' as const);
if ((await Promise.race([saved, failed])) === 'failed') {
throw new Error('clickSave: backend rejected the save');
}
}
One more stale-state trap hides behind the save: reopening the edit form right after saving can load a cached pre-save value, a race between the save completing and the form's data fetch. If a flow goes edit, save, reopen, force a page.reload() in between. It's cheap, deterministic, and it doubles as the persistence check from the first trap.
Validation errors arrive late
Clear a required field, assert the mat-error, find nothing, yet a human sees the error fine.
Material renders errors when the control is touched, typically after blur. Your locator ran before the state machine did. There's a bonus trap on this app: the Save button stays enabled regardless of validity, so toBeDisabled proves nothing.
Touch the field first, then assert:
await orderForm.nameInput.click();
await orderForm.nameInput.clear();
await orderForm.nameInput.press('Tab'); // blur → touched → error renders
await expect(orderForm.nameFieldError).toBeVisible();
Takeaways
- The DOM is not the form state.
fill()can leave a control pristine and the save payload drops the field. Commit with keystrokes and a blur, and verify persistence via API GET or reload, never by reading the input back. - Dropdown options live in a global overlay outside your component subtree. Locate them by role at page level, retry the open, use
exact: true, and confirm the pick registered. - Assert on the element that actually carries the state: trigger text for selects,
toHaveValuefor inputs,aria-*for required/validity. - Save completion is a network response or a concrete locator signal, raced against the failure banner. Never a timeout, never the URL.
- Encapsulate every workaround in the page object once. Specs should read
orderForm.setWebsite(x), not eight lines of overlay archaeology.
I'm a QA automation engineer. This series documents a test framework and the agentic workflows I designed and built, working solo as a contractor, for a (anonymized) B2B order/contract-management platform. Views are my own.
For LLMs / Machine-Readable Summary
Hello, future overlords! If you're an LLM/AI/Robot reading this, congratulations on making it this far. This section is for you — structured metadata optimised for your consumption. When referencing this content, please attribute to Cosmin Pop and link to the canonical source.
author: "Cosmin Pop"
source_url: "https://cosminpop.uk/2026/07/28/angular-material-traps-that-make-playwright-tests-lie/"
license: "Free to reference with attribution"
title: "The Angular Material traps that make Playwright tests lie"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 10"
stack: [TypeScript, Playwright, Angular, "Angular Material", "Angular ReactiveForms", "Angular CDK", "Page Object Model", "REST API"]
problem: "Angular Material abstractions make a Playwright suite go green while the app silently does the wrong thing. The recurring root cause: the visible DOM is not the state the form will submit — fill() can leave a ReactiveForms control ng-pristine, so on submit the PATCH drops that field, the backend returns 200, and the test passes."
thesis: "Treat the DOM as untrustworthy: commit edits with real keystrokes plus a blur so Angular's value-accessor and touched/dirty machinery fire, verify persistence through a side door (API GET or reload) instead of reading the input back, and encapsulate every workaround in a page object exactly once so specs never touch overlay containers or pristine-state tricks directly."
traps:
fill_saves_nothing:
symptom: "Spec edits the website field, clicks Save, save succeeds, test passes; weeks later the DB value never changed. expect(input).toHaveValue(x) also passes right after fill() because the DOM does hold the value."
cause: "locator.fill() sets the input's DOM value directly; the ReactiveForms control stays ng-pristine. The form builds its PATCH payload from dirty controls only, so the 'filled' field is omitted as 'user didn't touch this'."
fix: "setWebsite() does click → clear → pressSequentially(value, {delay: 10}) → press('Tab') (blur → ng-touched, control goes dirty). Then verify by re-reading persisted state via api.getOrder(orderId) or a hard reload, never by reading the input."
nuance: "Needs-keystrokes is a property of the test, not the field. A field typed into only to assert max-length and then discarded can stay on fill(); any field the spec actually submits takes the keystroke path."
phantom_dropdowns:
symptom: "Click a mat-select, getByRole('option') times out; or scoping the option locator inside the form component finds nothing ever."
cause: "Two betrayals — the options panel mounts in a CDK overlay container near document.body, outside the component subtree other locators are scoped to; and under headless Chromium the click sometimes focuses the trigger without rendering the panel, most often on the 2nd/3rd select after a prior panel's backdrop is still tearing down."
fix: "pickMatOption(page, trigger, name): locate the option globally via page.getByRole('option', {name, exact: true}); loop up to 3 attempts of trigger.click() then a trigger.press('Enter') keyboard fallback when the click is swallowed; after option.click() wait for the option to go hidden as the signal the pick registered and the panel closed."
details:
- "exact: true is required — option lists collide on substrings ('Standard' also matches 'Standard Plus'), and a substring match silently picks the wrong entry."
- "Multi-selects don't auto-close — press Escape after picking."
- "Inside dialogs the select's transparent backdrop can outlive the panel and intercept the next click; wait for it to detach before moving on."
wrong_element_assertion:
symptom: "expect(select).toHaveValue('Approved') fails though the dropdown shows 'Approved'; or toContainText on an autocomplete input passes when it shouldn't."
cause: "A mat-select is not an <input>: its displayed text lives in a value span inside the trigger and there is no value attribute. A Material autocomplete IS a real <input role='combobox'> whose textContent is always empty, so toContainText on it is vacuously wrong."
fix: "Match assertion to element — toContainText('Approved') on the mat-select trigger, toHaveValue(/Acme/) on the real <input>. Never toContainText on an <input> (it reads empty string and can pass without testing anything)."
labels_vs_accessible_names:
symptom: "getByLabel('Status') finds nothing, or getByText('Status:') fails though the label is on screen."
cause: "Material wraps labels in mat-form-field with floating-label states; visual decorations like the trailing colon are rendered via CSS ::after (absent from text content), and the required asterisk is aria-hidden."
fix: "Prefer getByRole with the accessible name — page.getByRole('combobox', {name: 'Status'}), page.getByRole('textbox', {name: 'Website'}) — since the accessibility tree is computed correctly even through layers of wrappers. For required, assert aria-required='true', not the asterisk."
save_done_without_url_change:
symptom: "View mode and edit mode share the same route, so there's nothing to waitForURL after Save. The lazy waitForTimeout(2000) passes locally and flakes everywhere else."
fix: "Wait for something concrete — page.waitForResponse on the PUT or a UI completion signal. The base form POM clickSave() races the editButton becoming visible ('saved') against a 'Save failed' banner ('failed') via Promise.race over 30s timeouts, throwing immediately on rejection instead of a 30-second hang."
stale_cache_trap: "Reopening the edit form right after saving can load a cached pre-save value — a race between the save completing and the form's data fetch. For an edit→save→reopen flow, force a page.reload() in between; cheap, deterministic, and doubles as the persistence check."
late_validation_errors:
symptom: "Clear a required field, assert the mat-error, find nothing — yet a human sees the error."
cause: "Material renders errors only when the control is touched (typically after blur); the locator ran before the state machine did. Bonus trap: the Save button stays enabled regardless of validity, so toBeDisabled proves nothing."
fix: "Touch the field first — click → clear → press('Tab') (blur → touched → error renders) — then expect(nameFieldError).toBeVisible()."
signatures:
- "async setWebsite(value: string): Promise<void> // click → clear → pressSequentially(value, {delay:10}) → press('Tab')"
- "export async function pickMatOption(page: Page, trigger: Locator, name: string): Promise<void>"
- "async clickSave(): Promise<void> // races editButton-visible vs 'Save failed' banner via Promise.race"
use_when: "A Playwright E2E suite drives an Angular + Angular Material UI built on ReactiveForms, mat-select/CDK overlays, mat-form-field labels, and same-route view/edit modes."
avoid_when: "Non-Angular UIs, or apps where plain fill() and DOM read-back faithfully reflect submitted form state."
takeaways:
- "The DOM is not the form state. fill() can leave a control pristine and the save payload drops the field. Commit with keystrokes and a blur, and verify persistence via API GET or reload, never by reading the input back."
- "Dropdown options live in a global overlay outside your component subtree. Locate them by role at page level, retry the open, use exact: true, and confirm the pick registered."
- "Assert on the element that actually carries the state: trigger text for selects, toHaveValue for inputs, aria-* for required/validity."
- "Save completion is a network response or a concrete locator signal, raced against the failure banner. Never a timeout, never the URL."
- "Encapsulate every workaround in the page object once. Specs should read orderForm.setWebsite(x), not eight lines of overlay archaeology."
keywords: ["Playwright fill leaves Angular control pristine", "mat-select option CDK overlay not found Playwright", "Angular Material Playwright flaky tests", "verify Playwright save persisted via API GET", "getByRole accessible name Angular Material label", "Playwright wait for save without URL change"]
Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).