PRACTICAL GUIDE / Playwright hasNot locator filter

Filter the right card with hasNot without losing locator scope

Filter Playwright locators with hasNot without crossing scope boundaries, masking loading states, or turning a clear selector into a strictness failure.

By The Testing AcademyUpdated August 7, 202622 min read
All field guides
In this guide6 sections
  1. Read hasNot from the outer element inward
  2. Work through different exclusion shapes
  3. Diagnose zero matches and strictness failures separately
  4. Separate duplicate records from duplicate presentations
  5. Choose between hasNot, hasNotText, and positive state
  6. Handle changing and virtualized lists without selecting by accident
  7. Roll out negative filters with contract tests

What you will learn

  • Read hasNot from the outer element inward
  • Work through different exclusion shapes
  • Diagnose zero matches and strictness failures separately
  • Choose between hasNot, hasNotText, and positive state

The test is meant to click the only order card without a Cancelled badge. It passes until a second active order arrives, then click() fails with a strict mode violation. The filter did exactly what it was asked; the test confused exclusion with unique selection.

Negative filters are useful when the page lacks a positive selector for the desired state. They also hide intent more easily than a role, name, or test ID. Before adding hasNot, decide what the outer candidates are, what descendant disqualifies one candidate, and how the remaining locator becomes unique.

Read hasNot from the outer element inward

Start with an outer locator that represents the repeated component: order cards, table rows, list items, or articles. Calling filter({ hasNot: inner }) keeps only outer matches that do not contain a match for inner. Playwright queries the inner locator starting from each outer element.

That relative evaluation is the mechanism. Suppose the document contains ten article elements. Playwright considers each article and asks whether the inner locator finds a descendant within that article. It does not ask whether the page contains the inner element somewhere else. A Cancelled badge in order A does not exclude order B.

The inner locator may be created from page, but it is still evaluated relative to the outer match when used as hasNot. It must make sense from that starting point. An inner selector that requires an ancestor outside the card cannot match because the search does not climb out to the document and begin again.

Outer and inner locators must belong to the same frame. The inner locator must not contain a FrameLocator. If the repeated components live in an iframe, create both locators from that frame. Cross-frame absence is a separate assertion, not one negative filter.

Locators are live queries rather than stored element lists. When an assertion or action uses the filtered locator, Playwright resolves it against the current DOM. A card can move into or out of the result if its descendant state changes. An earlier count() is diagnostic evidence for that moment, not a lock on the future selection.

The clean shape is outer candidates, a structural disqualifier, then a positive identifier or count.

TypeScript
import { test, expect } from '@playwright/test';

test('opens the non-cancelled order requested by the user', async ({ page }) => {
  await page.goto('/orders');

  const orders = page.getByRole('article');
  const cancelledBadge = page.getByRole('status', { name: 'Cancelled' });
  const openOrder = orders
    .filter({ hasNot: cancelledBadge })
    .filter({ hasText: 'Order #A-1042' });

  await expect(openOrder).toHaveCount(1);
  await openOrder.getByRole('link', { name: 'View order' }).click();
  await expect(page.getByRole('heading', { name: 'Order #A-1042' })).toBeVisible();
});

The first filter expresses state. The second expresses identity. toHaveCount(1) turns an unexpected duplicate or missing order into an assertion at the selection boundary, instead of letting click() report only that strict mode saw zero or multiple buttons.

There is a trade-off in using hasText for identity. Text can include descendant labels and may change with localization. If the application exposes data-testid="order-A-1042" or a stable accessible name for the article, prefer that positive contract. Keep hasNot focused on the one state the UI represents only through a descendant.

Work through different exclusion shapes

One negative locator pattern does not fit every component. A badge, an action button, and a loading skeleton carry different meaning even if all are descendants.

Exclude a semantic status badge. This is the most direct case. The component contains a status element whose accessible name is the disqualifying state. A self-contained test makes the set visible:

TypeScript
import { test, expect } from '@playwright/test';

test('keeps only jobs without a paused status', async ({ page }) => {
  await page.setContent(`
    <ul aria-label="Jobs">
      <li><span>Import customers</span><span role="status" aria-label="Running">Running</span></li>
      <li><span>Generate invoices</span><span role="status" aria-label="Paused">Paused</span></li>
      <li><span>Send receipts</span><span role="status" aria-label="Queued">Queued</span></li>
    </ul>
  `);

  const jobs = page.getByRole('listitem');
  const paused = page.getByRole('status', { name: 'Paused' });
  const available = jobs.filter({ hasNot: paused });

  await expect(available).toHaveCount(2);
  await expect(available).toContainText(['Import customers', 'Send receipts']);
});

The assertion checks the count and identities. A count of two by itself could pass after the wrong job is excluded and an unexpected job appears.

Exclude components that contain an action. Sometimes a row with a Retry button represents failure, while healthy rows do not offer Retry. The button is a structural signal, but its absence may be a weak proxy for health. Permissions can also hide buttons. Use this pattern only if the product contract defines retryability through the control.

TypeScript
import { test, expect } from '@playwright/test';

test('selects completed imports without a retry action', async ({ page }) => {
  await page.goto('/imports');

  const rows = page.getByRole('row').filter({
    hasNot: page.getByRole('button', { name: 'Retry import' }),
  });
  const customerImport = rows.filter({ hasText: 'customers.csv' });

  await expect(customerImport).toHaveCount(1);
  await expect(customerImport.getByRole('cell', { name: 'Completed' })).toBeVisible();
});

The positive Completed assertion prevents the test from treating a permission-restricted failed import as successful just because Retry is absent. Negative selection gets you to the candidate; a positive product state proves the claim.

Exclude a temporary skeleton. This is risky because a card without a skeleton may be loaded, failed before rendering a skeleton, or not started yet. Prefer waiting for the application's explicit ready state. If a component contract guarantees that data-testid="skeleton" exists only while loading and content appears afterward, assert both sides rather than immediately clicking the negative result.

TypeScript
import { test, expect } from '@playwright/test';

test('waits for scenario accounts to leave loading state', async ({ page }) => {
  await page.goto('/accounts');
  const cards = page.getByTestId('account-card');
  const settledCards = cards.filter({
    hasNot: page.getByTestId('skeleton'),
  });

  await expect(settledCards).toHaveCount(3);
  await expect(settledCards).toContainText(['North', 'South', 'West']);
});

The expected count must come from the scenario, not from whatever count happened to pass locally. If accounts are data-driven and variable, target a named account and assert its ready content instead of freezing the collection size.

Diagnose zero matches and strictness failures separately

Zero matches usually mean the outer locator is wrong, every candidate contains the disqualifier, or the inner locator is unintentionally broad. Multiple matches mean the filter worked but did not establish uniqueness. Treat those as different failures.

Run a focused test with the Playwright Inspector or a trace. The Inspector can highlight the live locator and show match count. In Trace Viewer, choose the failing action and inspect its locator, DOM snapshot, and call log. A strict mode violation reports that an action locator resolved to multiple elements; it does not mean hasNot is unsupported.

Use a temporary diagnostic assertion before the action. Split the chain into named locators and print a compact description of each outer candidate and the state it contains. evaluateAll() runs in the page against the matched elements and returns serializable data.

TypeScript
import { test, expect } from '@playwright/test';

test('prints the state of each order candidate', async ({ page }) => {
  await page.goto('/orders');
  const orders = page.getByRole('article');
  const cancelled = page.getByRole('status', { name: 'Cancelled' });
  const eligible = orders.filter({ hasNot: cancelled });

  console.log('outer count:', await orders.count());
  console.log('eligible count:', await eligible.count());
  console.log(await orders.evaluateAll(articles => articles.map(article => ({
    text: article.textContent?.replace(/\s+/g, ' ').trim(),
    statuses: Array.from(article.querySelectorAll('[role="status"]'))
      .map(status => status.textContent?.trim()),
  }))));

  await expect(eligible).toHaveCount(1);
});

Read this diagnostic in layers rather than treating the final count as the whole result. The first field, outer count, says whether the repeated-component boundary is plausible. The second, eligible count, says how many of those components survived the negative condition. The text and statuses fields then explain which business identities and states produced those totals. In an illustrative healthy run with three orders, the console could show an outer count of three, an eligible count of one, and three records whose status arrays contain Cancelled, Pending, and Cancelled. The one Pending record should carry the identity the scenario requested.

An illustrative broken run for the original failure can also show an outer count of three, but an eligible count of two. If the two surviving records contain different order identities and both have non-cancelled statuses, the page really contains two business candidates. The filter is behaving consistently, and the missing constraint is positive identity. By contrast, an outer count that looks correct can be misleading. One expected card can be absent while an unintended card takes its place, leaving the total unchanged. That is why the candidate identity text matters next to the counts. A green-looking eligible count: 1 is also misleading when the sole survivor is not the order named by the scenario.

Separate duplicate records from duplicate presentations

A second failure mode can produce almost the same strict mode message as a newly arrived active order: the UI renders two presentations of the same order at once. This can happen while an outgoing component and its replacement overlap, or when two layout containers both retain matching markup. The action still reports multiple matching View order controls. In the first failure mode, the data contains two eligible business records. In the second, the rendering layer has duplicated one business record. Adding another business-state filter may mask the second problem without correcting it.

The separating evidence is the identity of every surviving outer candidate in the same failing snapshot. If the two eligible articles carry different order numbers, and the scenario data or captured response contains both records, investigate test data and the missing positive identity. If both articles carry the same order number while the source record set contains it once, inspect their ancestors in the trace snapshot. Two copies under different presentation containers, or an old and new copy under the same component boundary, point to a rendering lifecycle problem. A hidden copy still contributes to locator multiplicity, so seeing one visible card in a screenshot does not prove that the locator resolved once.

Do not use the button names alone as identity evidence. Every order card may legitimately contain a control named View order, so the strictness log will list nearly indistinguishable controls for both root causes. Nor is a later console dump decisive. The duplicate presentation may be gone by the time the dump runs, while a live data update can create a genuine second record after an earlier dump. Compare business identity, ancestor context, and record source from the trace captured for the failing action. That common timestamp is what separates the cases.

Ownership follows that evidence. Different eligible identities usually lead back to scenario setup, shared-account isolation, or a selector that omitted identity. Repeated copies of one identity belong to the component rendering path unless the product intentionally presents the record twice. In that intentional case, scope the outer locator to the product-defined active region rather than adding a positional escape hatch. The two fixes can produce the same final count, but they correct different contracts.

This output is observation, not a replacement selector. Do not paste the entire normalized text into a production locator because it happened to distinguish one fixture dataset. Identify a stable role, name, ID, or product state after reading the diagnostic.

A zero result can come from a broad inner locator. page.getByText('Cancelled') matches text across descendants using Playwright's text rules. If every card contains a hidden menu item that says “Cancelled orders,” all cards may be excluded. A status role with an exact accessible name narrows the meaning. Inspect the matched inner element within one card before changing the outer selector.

An inner locator can also be impossible relative to the outer. Given an outer content element, an inner selector that starts with article div expects an article inside that content. It cannot use the article that contains the content because relative evaluation starts at the content. Rewrite the inner locator to describe a descendant from the outer boundary.

Dynamic lists create a timing near-miss. The first count() may see one eligible card, then a live update adds another before click(). The click re-resolves the locator and correctly reports two. Do not replace it with .first(), which silently selects whichever item currently sorts first. Add a stable identity or wait for a product signal that says updates for the scenario are complete.

The diagnostic command should isolate the test and retain a trace from the same attempt:

Shell
npx playwright test tests/orders.spec.ts:24 --workers=1 --retries=0 --trace=on
npx playwright show-trace test-results/**/trace.zip

If the failure occurs only under parallel data creation, one worker may make it disappear. Use one worker first to understand selector semantics, then repeat with the normal worker count and unique test data. Locator fixes cannot compensate for tests that create indistinguishable orders in a shared account.

Choose between hasNot, hasNotText, and positive state

hasNot excludes by another locator. That lets you use roles, names, test IDs, CSS selectors, or other locator composition to describe structure. hasNotText excludes elements whose descendant text contains the supplied string or matches the supplied regular expression. For a string, matching is case-insensitive and checks for a substring.

That substring rule is convenient and sharp. Excluding "paid" may also exclude “unpaid.” Use a regular expression with appropriate boundaries when text is truly the contract, and test it against the content your component can render.

TypeScript
import { test, expect } from '@playwright/test';

test('excludes the exact out-of-stock label', async ({ page }) => {
  await page.setContent(`
    <ul aria-label="Products">
      <li>Notebook <span>In stock</span></li>
      <li>Marker <span>Out of stock</span></li>
      <li>Folder <span>Stock arriving</span></li>
    </ul>
  `);

  const available = page.getByRole('listitem').filter({
    hasNotText: /\bOut of stock\b/i,
  });

  await expect(available).toHaveCount(2);
  await expect(available).toContainText(['Notebook', 'Folder']);
});

Even this example would be stronger with a status or availability attribute because copy changes and localization are product concerns. Use hasNotText when the user-visible phrase is the requirement, not because it is faster to type than a semantic locator.

CSS :not() is different. It negates a selector against the element being selected. It does not by itself express “this card lacks a descendant matching X.” Modern CSS can combine :not() and :has(), but Playwright's hasNot makes the relative locator boundary explicit and composes with user-facing locators. Choose the clearest supported contract rather than translating every filter into CSS.

Positive state is usually easier to trust. If a card has data-state="active", locate that state directly and assert its user-visible result. A negative filter says only what is absent. It may admit new states such as Pending review, Unknown, or Permission denied. When the business requirement is “active,” select or assert active rather than “not cancelled.”

Frames need deliberate scope. The following example obtains a Frame object and creates both outer and inner locators from it. It does not pass a locator from the main page into the filter.

TypeScript
import { test, expect } from '@playwright/test';

test('filters rows within one named frame', async ({ page }) => {
  await page.setContent(`
    <iframe name="queue" srcdoc="
      <table>
        <tr><td>Build 17</td><td><span role='status' aria-label='Running'>Running</span></td></tr>
        <tr><td>Build 18</td><td><span role='status' aria-label='Paused'>Paused</span></td></tr>
      </table>">
    </iframe>
  `);

  const frame = page.frame({ name: 'queue' });
  expect(frame).not.toBeNull();

  const rows = frame!.getByRole('row');
  const paused = frame!.getByRole('status', { name: 'Paused' });
  await expect(rows.filter({ hasNot: paused })).toHaveCount(1);
});

If the excluded condition lives in another frame, make two observations. Filter the target within its frame using local state, then assert the other frame separately. A cross-frame negative relationship often indicates that the test is encoding application coordination in a selector.

Handle changing and virtualized lists without selecting by accident

A filtered locator is evaluated each time it is used. That is normally an advantage: Playwright does not hold a stale DOM node while a component rerenders. It also means the result can legitimately change between two assertions when the application changes descendant state.

The following self-contained test keeps one locator and proves both states. After the Flag button runs, the Beta card gains an alert descendant and stops satisfying hasNot. No new locator is required because the existing one resolves against the current DOM.

TypeScript
import { test, expect } from '@playwright/test';

test('filtered results follow descendant state changes', async ({ page }) => {
  await page.setContent(`
    <section aria-label="Deployments">
      <article>
        <h2>Alpha</h2>
      </article>
      <article id="beta">
        <h2>Beta</h2>
      </article>
    </section>
    <button onclick="
      const warning = document.createElement('p');
      warning.setAttribute('role', 'alert');
      warning.textContent = 'Approval required';
      document.getElementById('beta').appendChild(warning);
    ">Flag Beta</button>
  `);

  const cards = page.getByRole('article');
  const withoutWarnings = cards.filter({
    hasNot: page.getByRole('alert'),
  });

  await expect(withoutWarnings).toHaveCount(2);
  await page.getByRole('button', { name: 'Flag Beta' }).click();
  await expect(withoutWarnings).toHaveCount(1);
  await expect(withoutWarnings.getByRole('heading')).toHaveText('Alpha');
});

That live behavior makes this sequence unsafe: count the result, wait for unrelated asynchronous work, then call first().click(). The collection may have changed and first() makes the new choice silently. Carry a positive identity into the action, such as the Alpha heading or deployment ID. If the intended item becomes disqualified, the named locator should reach zero and fail rather than choosing its neighbor.

Optimistic interfaces create a specific version of this race. A user clicks Cancel, the UI immediately adds a Cancelled badge, and the server later rejects the cancellation and removes it. A locator for cards without that badge can exclude the card temporarily, then admit it again. Decide which durable state the test needs. Wait for the API-backed status or a product completion signal before using the negative result. A fixed delay only samples the animation at another arbitrary point.

Loading indicators cause the reverse mistake. A row without a spinner is not necessarily ready. It may not have started loading, may have failed before the spinner appeared, or may use a different indicator at a narrow viewport. If the product exposes a Ready status, assert Ready. If the scenario knows the expected record, locate that record and wait for its content. Treat “does not contain spinner” as a secondary condition, not the whole definition of success.

Virtualized lists add a separate boundary. They usually render only a window of rows in the DOM while the rest of the dataset remains in application memory or on the server. Playwright locators query the DOM. A row that is not rendered cannot be included or excluded by hasNot, and a count of filtered DOM rows is not a count of all records.

As an illustrative case, suppose the product shows 500 jobs but renders 20 visible rows. A locator for rows without a Failed badge may report the eligible subset among those rendered rows. Scrolling changes the DOM window, so the result count and identities change. That is not selector flakiness. The test asked a DOM question when the requirement was a dataset question.

Use the product's search, filter, or stable row navigation to bring the intended record into the rendered window. Then apply hasNot within that named row if descendant absence is still the contract. If the requirement is “no job in the entire dataset has failed,” verify it through a product API or UI summary designed to represent the whole dataset. Do not repeatedly scroll and accumulate text unless exercising virtualization itself is the purpose of the test.

The next pattern targets one virtualized row by user-facing identity, waits for it to render, then checks the negative descendant condition. It does not claim anything about offscreen records.

TypeScript
import { test, expect } from '@playwright/test';

test('opens a rendered build only when it has no failure badge', async ({ page }) => {
  await page.goto('/builds');
  await page.getByRole('searchbox', { name: 'Filter builds' }).fill('Build 1842');

  const build = page.getByRole('row')
    .filter({ hasText: 'Build 1842' })
    .filter({
      hasNot: page.getByRole('status', { name: 'Failed' }),
    });

  await expect(build).toHaveCount(1);
  await build.getByRole('link', { name: 'Open build' }).click();
  await expect(page.getByRole('heading', { name: 'Build 1842' })).toBeVisible();
});

This costs an extra product interaction and couples the test to the filter control, but it preserves identity. Directly using rows.filter({ hasNot: failed }).first() is shorter and can open the wrong build as the virtual window changes.

Avoid using locator.all() to wait for a dynamic list. Playwright documents that all() returns the elements currently present and does not wait for the list to finish loading. First use an auto-retrying assertion tied to an expected state, then inspect individual locators only if the list is stable. On variable datasets, expected identity is a better synchronization condition than a guessed count.

When a rerender and a strictness error occur together, inspect the trace snapshots immediately before the action. The final snapshot shows the candidates Playwright actually resolved then. A console dump taken several seconds earlier can describe a different DOM and should not overrule the trace.

Roll out negative filters with contract tests

Find existing chains that use .first(), .last(), or positional selectors after broad locators. They often hide the same uniqueness problem that appears when adding hasNot. Record the business identity each action intended to target before changing syntax.

Add the filter in named stages: outer candidates, disqualifying descendant, positive identity, expected count, then action. This is slightly longer than one chain, but failure output identifies the broken boundary. Keep diagnostics such as evaluateAll() out of the final test unless their output is safe and consistently useful.

For an existing suite, land the supporting contract before replacing selectors. Stabilize the scenario setup for the recorded business identity. If the component lacks a stable accessible name, state marker, or test-facing identity, the product change that supplies that contract must be available before the test depends on it. Next land focused component contract coverage before broad end-to-end specs consume the new selector boundary. Only then replace the positional selector in one spec group, add the cardinality assertion, and remove the positional fallback from that group. Expand to the next group after failures from the first migration have been classified.

The first breakage is usually not the click. The new count assertion exposes fixtures that leave two qualifying records in a shared account, tests whose named record never rendered, and pages that duplicate one presentation. Those tests may have passed because .first() converted all three conditions into an arbitrary choice. Preserve the assertion failure and inspect its candidates instead of restoring the positional selector. If an application contract and test update ship separately, keep the old contract working until the test suite has moved, then remove it in a later change. Reversing that order creates zero-match failures that reveal nothing about hasNot semantics.

Prove the rollout with adversarial fixture changes, not only an unchanged green run. Add a disqualifying descendant to the non-target card and verify that the named target remains. Then give a second card a qualifying state and verify that the identity constraint still leaves one. Finally, duplicate the target presentation in a component-level fixture and confirm that cardinality fails. The last check prevents a future .first() from quietly returning during cleanup. In CI, the useful signal is that an invalid candidate set now fails at the count boundary with identities visible in the retained evidence, while valid scenarios continue to reach the intended detail page.

Test at least three component states in a small page or component fixture: a qualifying item, a disqualified item, and an unexpected new state. Decide whether that new state should qualify. A negative rule that automatically admits every future state is a product decision hidden inside test code.

CI should retain a trace for the original selector failure and use deterministic test identities. A minimal configuration keeps screenshots and traces from failed attempts, while a diagnostic command can turn retries off.

TypeScript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 1 : 0,
  use: {
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
  },
  reporter: [['line'], ['html', { open: 'never' }]],
});

Expect a maintenance cost when component markup changes. A role-based status locator couples the test to accessible semantics, which is often valuable because users depend on them too. A test-ID descendant is more stable against copy changes but can drift away from what users perceive. Choose that trade-off intentionally.

There is also a concrete runtime and coverage cost. An auto-retrying count assertion can consume the suite's configured assertion timeout when the candidate set never becomes valid, whereas a strict click on a permanently duplicated set can fail sooner. Bringing a virtualized record into view through product search adds another interaction and often another request-and-render cycle to every affected test. Maintaining three explicit component states increases fixture surface area whenever the component gains a new status. Positive identity also stops the test from incidentally exercising whichever record sorts first, which is less accidental breadth but more deliberate fixture work. These costs are preferable only when the record identity matters to the scenario.

Split cross-team ownership by the failed contract. The automation owner owns the minimal reproduction, locator boundary, cardinality assertion, and evidence that the intended business identity was selected. The frontend owner owns duplicated presentations and missing or misleading semantic descendants. The service or test-data owner owns duplicate records and nondeterministic setup. A useful handoff includes the failing trace, exact outer and inner locator descriptions, candidate counts and identities from the failing moment, frame and viewport context, scenario setup, expected state transition, and whether the record source contained one identity or several. A screenshot without the candidate dump cannot distinguish the two strictness failures, and a candidate dump without scenario data cannot show whether the extra record was legitimate.

This technique does not catch stale or false UI state. If the service has cancelled an order but the card fails to render its Cancelled badge, hasNot admits the card because it can judge only the DOM contract it was given. Verify the durable state through the detail view or another product contract when backend agreement is part of the requirement. Filtering descendants also cannot prove that a correctly selected View order link is wired to the correct record, so the destination identity assertion remains necessary.

Do not use hasNot to make a non-unique locator “usually unique,” to cross frame boundaries, to infer success only from the absence of an error, or to skip items still loading. Avoid it when the product exposes a clear positive state. Use it when a repeated component is genuinely defined by lacking one specific descendant, then pair the negative condition with positive identity and an explicit cardinality assertion.

// 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.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

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.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

What does hasNot mean in a Playwright locator filter?

It keeps each outer element that does not contain an element matching the inner locator. Playwright evaluates that inner locator relative to each outer match rather than from the document root.

Why does my hasNot filter return more than one element?

Excluding one descendant state does not guarantee the remaining set is unique. Assert the expected count or add a positive business identifier before using a strict action such as `click()`.

Can hasNot use a locator from another iframe?

No. The outer and inner locators must belong to the same frame, and the inner locator must not contain a FrameLocator. Build both locators inside the target frame or assert cross-frame state separately.

Should I use hasNot or hasNotText?

Choose `hasNot` when the excluded condition has structure or semantics, such as a status badge or button. Use `hasNotText` when text itself is the contract, remembering that string matching is case-insensitive substring matching across descendant text.

Does hasNot wait for a child element to disappear?

A locator is evaluated when it is used, and auto-retrying assertions can wait for the filtered result to reach an expected state. The filter alone does not define which application transition means the list has finished loading.