PRACTICAL GUIDE / Playwright hasNotText filter
Use negative text filters without selecting the wrong element
Narrow Playwright locators with hasNotText, expose empty-list false positives, and replace fragile copy exclusions with stronger product state.
In this guide6 sections
- Read the filter as a collection operation
- Exclude a status without losing product identity
- Distinguish descendant text from accessible structure
- Diagnose empty, broad, and moving candidate sets
- Introduce the filter without hiding existing ambiguity
- Prefer positive state when exclusion is not the requirement
What you will learn
- Read the filter as a collection operation
- Exclude a status without losing product identity
- Distinguish descendant text from accessible structure
- Diagnose empty, broad, and moving candidate sets
The test means to buy the only available product, but its locator matches three cards and fails in strict mode. Someone adds .first(), and the test turns green while clicking whichever card happens to render first. A negative text filter can narrow the collection safely, but only if the excluded copy really represents the product state you care about.
Read the filter as a collection operation
Playwright locators are live queries. A locator describes how to find elements when an assertion or action uses it; it is not a frozen array captured at declaration time. Calling filter({ hasNotText }) returns another locator whose candidates come from the existing locator and whose text must not match the supplied string or regular expression.
The outer locator matters. Starting from page.getByRole('listitem') asks Playwright to evaluate each list item. Starting from page.getByRole('list') asks it to evaluate the whole list as one candidate. If one child row contains “Out of stock,” a negative filter on the list can reject the entire list. The same filter on list items rejects only matching rows. Many mysterious zero counts are scope mistakes, not text-matching defects.
Text can occur on the candidate itself or in a child or deeper descendant. That is useful for cards where a status badge sits several elements below the <li>. It is also broad. A footer, tooltip container, nested recommendation, or button label rendered inside the card can affect membership even when the heading looks unrelated. Inspect the candidate boundary in the DOM before deciding the filter is wrong.
When given a string, hasNotText uses case-insensitive substring matching. The string Out of stock therefore excludes text containing that phrase in a different case and also excludes longer copy such as Temporarily out of stock online. A regular expression gives control over case, word boundaries, and alternatives. That control is not automatically better. An overfitted regex can encode punctuation and wording that the product team changes next week.
The method narrows; it does not assert. A filtered locator can contain zero elements, one element, or fifty elements. click() and many other actions require a unique target, so a multi-match locator will produce a strict-mode error. toHaveCount() is the clearest way to state collection cardinality. When the business rule expects one specific card, add positive identity as well as the negative status and assert the final count before clicking.
Negative logic creates a special false-positive risk. Suppose a test expects no eligible rows and writes expect(rows.filter({ hasNotText: /blocked/i })).toHaveCount(0). That passes if every loaded row says “Blocked,” but it also passes if the request failed and no rows rendered. The oracle cannot tell those states apart. Prove a ready marker, response, or base-row count first. Absence is meaningful only after presence and loading boundaries are known.
Do not confuse hasNotText with a negative assertion. expect(card).not.toContainText('Out of stock') checks a chosen card. cards.filter({ hasNotText: 'Out of stock' }) chooses all cards lacking the phrase. One is an assertion about an identified element; the other changes the candidate set. A test often needs both an identity assertion and a state assertion, especially before a destructive action.
The opposite filter, hasText, is not a required companion. Chaining hasText: 'Pro' and hasNotText: 'Unavailable' can be clear when copy is the only public contract. If the application exposes a heading, role, stable product code, or enabled “Add to cart” button, use those positive signals for identity and keep text exclusion focused on status.
Exclude a status without losing product identity
A useful test states which product it wants and why that product is eligible. The example below is self-contained, so it proves Playwright's selection behavior rather than depending on a changing catalog. Two products are available and one is not. The first assertion checks the collection rule. The second narrows by a positive heading before clicking.
import { expect, test } from '@playwright/test';
test('buys a named product only when its card lacks the stock warning', async ({ page }) => {
await page.setContent(`
<ul aria-label="Products">
<li>
<h2>Alpha keyboard</h2>
<p>Ships today</p>
<button>Add to cart</button>
</li>
<li>
<h2>Beta mouse</h2>
<p>Out of stock</p>
<button disabled>Add to cart</button>
</li>
<li>
<h2>Gamma headset</h2>
<p>Low stock</p>
<button>Add to cart</button>
</li>
</ul>
<output role="status" aria-live="polite"></output>
<script>
document.querySelectorAll('button').forEach(button => {
button.addEventListener('click', () => {
const name = button.closest('li').querySelector('h2').textContent;
document.querySelector('output').textContent = name + ' added';
});
});
</script>
`);
const cards = page.getByRole('listitem');
const available = cards.filter({ hasNotText: /out of stock/i });
await expect(available).toHaveCount(2);
const alpha = available.filter({
has: page.getByRole('heading', { name: 'Alpha keyboard' }),
});
await expect(alpha).toHaveCount(1);
await alpha.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByRole('status')).toHaveText('Alpha keyboard added');
});Removing the stock warning from Beta makes the first count fail because three cards become eligible. Renaming Alpha makes the identity count fail. Breaking the click handler makes the status assertion fail. Each assertion observes behavior that product changes can invalidate, so the example avoids a dead oracle.
Why assert two eligible products if the test buys only Alpha? The count guards the category rule shown by the fixture. In a real catalog test, that broad count is useful only when the dataset is controlled. If another product can be added by an administrator, asserting an exact catalog-wide count creates noise. Scope to a seeded test collection, category, or response identifier instead of making a mutable production total part of checkout behavior.
The disabled button on Beta is a second signal. In a mature product, that positive control state is usually stronger than copy. The stock label may be localized or changed by marketing, while the unavailable action must remain disabled for accessibility and correctness. A test can select cards whose Add button is enabled, or locate the named product and assert its button state directly. Use hasNotText when the visible exclusion itself is the requirement, not merely because it is available.
Copy can contain semantic traps. Not out of stock contains the substring out of stock, so the filter excludes it even though a human may read the sentence as available. Out of stock in store, available online also contains the phrase but represents mixed availability. A text filter does string matching, not language understanding. If the product has channel-specific states, select the status element for the relevant channel or expose a structured attribute.
Localization changes the risk profile. A regex covering English, French, and German stock phrases is hard to review and still incomplete. If the test's purpose is translation, assert the localized copy for a known state. If its purpose is purchasing eligibility, identify the state through a language-independent contract and separately test each translation. Combining both purposes in one negative regex makes failures hard to classify.
A title or description containing the excluded phrase can also remove the card. Imagine a book named “Surviving the Out of Stock Era.” A card-wide filter sees the title and status as one text scope. Narrowing hasNotText cannot target only a descendant status element. In that situation, use hasNot with a locator for the status badge, or locate the card by identity and assert the badge is absent.
Distinguish descendant text from accessible structure
An accessible name is not the same thing as descendant text. A button can have no text node and receive its name from aria-label. An SVG status icon can expose aria-label="Discontinued" without rendering the word as a child text node. hasNotText is not a role or accessible-name query, so it is the wrong tool when the exclusion is defined by accessible structure.
The following example deliberately shows the near-miss. A text filter keeps both cards because “Discontinued” lives in an ARIA attribute. A structural hasNot filter excludes the card containing an image with that accessible name.
import { expect, test } from '@playwright/test';
test('uses hasNot when status is exposed through an accessible icon', async ({ page }) => {
await page.setContent(`
<ul aria-label="Plans">
<li>
<h2>Starter</h2>
<svg role="img" aria-label="Discontinued"></svg>
</li>
<li>
<h2>Team</h2>
<svg role="img" aria-label="Available"></svg>
</li>
</ul>
`);
const plans = page.getByRole('listitem');
const textOnly = plans.filter({ hasNotText: /discontinued/i });
await expect(textOnly).toHaveCount(2);
const offered = plans.filter({
hasNot: page.getByRole('img', { name: 'Discontinued' }),
});
await expect(offered).toHaveCount(1);
await expect(offered.getByRole('heading')).toHaveText('Team');
});This distinction explains a common review argument in which the screenshot “clearly says discontinued” but a text filter does not behave as expected. Assistive technology can receive a name from an attribute while no matching text node exists. Conversely, descendant text can include copy that contributes nothing to an interactive element's accessible name. Inspect both the DOM text and accessibility semantics rather than treating either as a universal view of the component.
hasNot accepts a locator and evaluates it relative to each outer candidate. The inner locator must belong to the same frame and should describe something inside the candidate. Starting the inner query from a list outside the card defeats that relative scope. Playwright's locator guide calls this out because an apparently reasonable chain can search from the wrong boundary and return no matches.
Role-based structure is often more durable. A card with an enabled “Add to cart” button, no “Unavailable” status element, and a named heading gives three independently reviewable signals. Text across the entire card merges them. Prefer the smallest descendant that owns the state. If designers replace a word badge with an icon but preserve its accessible role and name, the structural locator can survive while a text filter correctly signals that its old contract disappeared.
Do not use CSS class names such as .red or .muted as the immediate replacement. Those describe presentation, not availability. A visual refactor can change them without changing behavior, and two product states may share a color. If no semantic locator exists, ask the application team for a stable status attribute or test identifier tied to the domain state. That small change usually costs less than maintaining negative copy across the suite.
Shadow DOM follows Playwright's general locator behavior, but closed shadow roots remain outside normal locator access. A card that renders status in an open shadow tree may still participate in locator matching. If a third-party component hides status in a closed root, do not claim hasNotText validates it. Assert the component's public accessible output or the user action it enables.
Frames are another hard boundary. An inner hasNot locator cannot cross from an outer locator in one frame to content in another. If each row hosts an iframe, query the frame explicitly and test its content as a separate boundary. A card-level negative text filter cannot prove an embedded application lacks a warning.
Diagnose empty, broad, and moving candidate sets
The fastest useful diagnostic is a snapshot of every outer candidate before applying the filter. Record index, trimmed text, stable identifiers, and interactive descendants. Attach it to the failed test rather than printing an unbounded page body. That evidence shows whether the base locator was empty, whether an unexpected descendant supplied the excluded phrase, and whether two candidates remained before a strict action.
import { expect, test } from '@playwright/test';
test('attaches the candidate set when the availability rule fails', async ({ page }, testInfo) => {
await page.goto('/products?fixture=stock-states');
await expect(page.getByTestId('catalog-ready')).toHaveText('ready');
const cards = page.getByRole('listitem');
await expect(cards).toHaveCount(3);
const snapshot = await cards.evaluateAll(elements => elements.map((element, index) => ({
index,
productId: element.getAttribute('data-product-id'),
text: element.textContent?.replace(/\s+/g, ' ').trim() ?? '',
buttons: Array.from(element.querySelectorAll('button')).map(button => ({
text: button.textContent?.trim() ?? '',
ariaLabel: button.getAttribute('aria-label'),
disabled: button.disabled,
})),
})));
await testInfo.attach('product-card-candidates', {
body: Buffer.from(JSON.stringify(snapshot, null, 2)),
contentType: 'application/json',
});
const eligible = cards.filter({ hasNotText: /out of stock/i });
await expect(eligible).toHaveCount(2);
});The readiness assertion comes before the negative rule. Without it, a temporarily empty list could satisfy a zero-count expectation or produce a misleading candidate attachment. Choose a readiness signal owned by the application, such as a completed status, known fixture response, or disappearance of a loading indicator. A fixed timeout is not evidence that loading finished.
When Playwright reports a strict-mode violation, read the number of resolved elements and the previews it prints. The message from a click is locator.click: Error: strict mode violation: locator('...') resolved to 2 elements:, followed by one numbered preview per line. Mind the Error: segment between the API name and the phrase: a log filter written for the shorter locator.click: strict mode violation matches nothing on Playwright 1.61, which is a quiet way to lose every one of these failures from a dashboard. Match on strict mode violation alone if you want a filter that also survives a change of API name. However you match it, the message means the action received more than one target. It does not mean the first matching element was invisible or that Playwright should choose for you. Add an identity or assert the count. Replacing the action with .first() discards information and can turn a real ambiguity into a wrong click.
An assertion timeout has a different shape. toHaveCount(2) repeatedly evaluates the locator until the expectation timeout expires. Its call log shows the locator and received counts over time. If the received count changes from three to two, the stock label arrived after the cards. If it stays at zero, inspect the outer locator or readiness. If it stays at three, inspect candidate text and the pattern.
Dynamic pages can make a correct filter unstable. A product may move from “Checking stock” to “Out of stock” after the locator is declared. Because locators are live, the same locator can represent a different set at the next action. That is usually desirable, but a test that checks count and clicks later can cross a state transition between those lines. Wait for the catalog's settled state, then identify the product by a stable key and assert its action remains enabled at click time.
Virtualized lists add another near-miss. Only a window of rows may exist in the DOM, so filtering current list items does not describe the entire dataset. Scrolling can recycle row elements with new text. Do not assert “all orders exclude Cancelled” by counting only rendered rows unless the component contract defines that viewport. Test the data query at the API layer or exercise the virtualized control's documented navigation.
Whitespace and casing should be diagnosed from the actual candidate string. String matching is case-insensitive and substring based, but a regular expression without i is case-sensitive. If a regex copied from a unit test fails in Playwright, log the normalized diagnostic text you chose and review its flags. Avoid building a regex from unescaped user or fixture content; metacharacters can change its meaning.
Trace Viewer completes the picture. Select the failing assertion or action, then compare before and after DOM snapshots. Confirm that the expected rows existed, that loading had completed, and that status copy sat inside the outer candidate. The locator call view tells you which chain ran. A screenshot alone cannot show hidden descendants, ARIA attributes, or whether the query began at the list instead of each row.
Introduce the filter without hiding existing ambiguity
Migration should start with observation. Find locators that currently use .first(), .nth(), broad CSS selectors, or exact counts after a negative assertion. Run them against controlled data and attach candidate snapshots for failures. Do not replace every .first() mechanically. Some lists have a documented order, while others need a positive business identifier.
For each candidate, write the rule in plain language: “the Pro plan that is not retired,” “all orders that are not cancelled,” or “the first chronological result.” Those are different selectors. The first combines positive identity and negative state. The second is a collection assertion. The third legitimately depends on order and should prove the sort. A shared helper named activeItems() is useful only if “active” maps to one stable product contract.
Add cardinality assertions before actions during rollout. They make failures noisier for a short time, but they expose hidden ambiguity instead of choosing around it. Once the suite is stable, keep assertions where uniqueness is part of the behavior. A count placed only to satisfy strict mode, with no domain reason for the number, is a maintenance smell.
Use trace retention on the first retry or on failure rather than recording every passing list assertion. The configuration below keeps the evidence needed to inspect locator candidates without imposing full-time trace cost.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
retries: process.env.CI ? 1 : 0,
expect: { timeout: 7_000 },
reporter: [['html', { open: 'never' }], ['line']],
use: {
...devices['Desktop Chrome'],
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});Do not raise the expectation timeout until candidate evidence shows that the correct settled state arrives late. A broad locator that never narrows will simply fail more slowly. If the inventory call is the bottleneck, wait on the product's loading contract or stabilize the test environment. If labels stream independently by design, assert the transition that matters instead of guessing a duration.
Review filtered locators under the locales and responsive layouts your suite supports. Responsive rendering may move status outside the card or replace text with an accessible icon. Localization may change copy while the domain state stays constant. One semantic positive locator often survives both changes; a card-wide negative phrase often does not.
Keep the first failed attempt when a retry passes. A retry can receive faster data, a different sort order, or a warm cache and therefore avoid the ambiguous moment. The original trace is the evidence of a race. Marking the case “fixed by retry” loses the exact state the filter needs to handle.
Prefer positive state when exclusion is not the requirement
Negative filters are appropriate when absence of particular copy is itself observable behavior. They are also useful while exploring a page or narrowing a stable list for a read-only assertion. They are a poor foundation for high-impact actions when a positive eligibility signal exists.
Do not select a payment method because its card does not contain “Unavailable.” Select the named method, assert it is enabled, and then choose it. Do not delete the first user row that does not say “Owner.” Locate the intended account by immutable identifier and assert its role permits deletion. Negative copy says what a candidate is not; it often fails to say what it is.
Avoid hasNotText when the data is better verified before rendering. If an API response must exclude suspended accounts, test the response schema and filtering logic directly, then keep one UI case for the visible result. Browser-level negative scans over hundreds of rows cost time and still miss virtualized or paginated data.
Do not use it to make a locator unique after a design regression. If two “Save” dialogs are visible and one contains stale error text, filtering away the error may click the other dialog while leaving the duplicate-overlay bug undetected. Assert that the correct dialog is the only active one. A selector should not normalize an invalid UI state unless the product explicitly supports multiple dialogs.
Copy experiments are another warning. If product wording changes behind a feature flag, a negative phrase can split the candidate set by cohort. Stable status attributes or roles let the same behavioral test run across copy variants. Test the copy experiment separately with its flag and expected text.
Pagination can create a convincing false claim. A filter applied to the first page proves only that the rendered page lacks the phrase. It does not prove every result in the account is active or every audit row excludes an error. When the requirement covers the whole dataset, test the server query or iterate the product's documented pagination controls and identify each page. Do not rename a viewport assertion “all records” because the locator itself cannot see unloaded data.
Server-side filtering is another separate contract. If a request asks for status=active, a clean UI list may mean the server filtered correctly, the client discarded suspended rows, or the fixture contained no suspended data. Seed at least one excluded record and inspect the response when ownership matters. The UI assertion then proves that no excluded record was rendered, while the API assertion proves which layer enforced the rule.
For a controlled fixture, check both sides of the partition. If five rows load, two match hasText: /cancelled/i, and three match hasNotText: /cancelled/i, the counts account for the whole fixture. That catches a pattern that accidentally matches every row or no row. Do not use the arithmetic against mutable production data, and remember that one row can contain several status phrases if the UI exposes history.
Keep filtered locators as locators until the assertion or action. Converting candidates into text arrays for selection freezes one moment and loses Playwright's retry behavior. Arrays are excellent diagnostic attachments, as shown earlier, but they should not become a second selector engine in test code. Let the locator choose; use the snapshot to explain why it chose.
Security-sensitive exclusions need positive authorization checks. Hiding a “Delete” label from a guest card does not prove the deletion endpoint rejects the guest. Use the UI test for discoverability and an API or service test for authorization. A negative text locator is not access control evidence, even when the screen looks correct.
The trade-off is maintenance versus visibility. Text filters are readable and require no application changes. They also couple selection to every descendant's copy, localization, and render timing. A test identifier or domain attribute costs a small amount of product code but creates a narrower contract. Accessible roles cost disciplined markup and improve both testing and user access. Choose the strongest signal the team can own.
A broad helper that accepts arbitrary excluded phrases and returns .first() centralizes the two riskiest decisions while hiding them from the test. A good helper returns the collection, exposes its domain name, and lets the caller assert identity and count. The test should remain capable of telling you whether the list never loaded, every row was excluded, or several valid candidates remained.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
FAQ / QUICK ANSWERS
Questions testers ask
Is hasNotText case-sensitive in Playwright?
A string value performs a case-insensitive substring match. A regular expression follows the flags and pattern you provide, so add the i flag when case should not matter.
Why does hasNotText still return several elements?
Filtering narrows a collection; it does not promise one result. Assert the expected count or add a positive identity filter before an action that requires a unique target.
Does hasNotText check aria-label text?
Text filtering is not an accessible-name query, so an aria-label is not a substitute for descendant text. Use a role locator inside hasNot when the exclusion is defined by accessible structure.
Can a negative text count pass when the list never loaded?
An expected count of zero can pass for an empty base locator as well as for a populated list whose rows were all excluded. Prove the list's ready state or seed count before relying on the negative result.
When should I avoid hasNotText?
Prefer a positive status, enabled control, stable attribute, or domain identifier when one exists. Negative copy is useful for exploration, but localization and wording changes make it a weak long-term identity.
RELATED GUIDES
Continue the learning route
GUIDE 01
How to Take Screenshots in Playwright
Learn how to take screenshots in Playwright for full page, element, and failure captures, plus visual checks and practical CI debugging tips.
GUIDE 02
How to Run Tests in Parallel with Playwright
Learn how to run tests in parallel with Playwright: workers, sharding, isolation, CI config, flakiness fixes, and a practical checklist for faster suites.
GUIDE 03
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 04
Playwright vs Selenium for Beginners
Compare Playwright vs Selenium for beginners: setup, syntax, waits, browsers, debugging tips, and which automation tool to learn first in 2026.
GUIDE 05
Test Canonical URLs with Playwright
Build Playwright test canonical URLs checks for rendered link tags, absolute hrefs, redirect variants, indexable routes, and metadata regressions in CI.