Resource library

QA How-To

Playwright locator filter: Examples and Best Practices

Use playwright locator filter examples for cards, rows, text, exclusions, visibility, frames, dynamic lists, page objects, and reliable TypeScript tests.

22 min read | 2,594 words

TL;DR

Locate the repeated component first, call filter() with hasText, has, hasNotText, hasNot, or visible, and scope the action inside the result. Assert readiness and uniqueness when the product requires one target.

Key Takeaways

  • Express filters as a container-to-descendant product relationship.
  • Prefer exact heading or cell locators when structure carries identity.
  • Pair negative filters with positive identity and a proven loaded state.
  • Use visible filtering only for intentional active and inactive DOM variants.
  • Build both sides of has inside the same frame.
  • Use retrying locator assertions for dynamic filtered collections.
  • Return lazy Locators from component objects instead of static elements.

These playwright locator filter examples solve a common automation problem: many cards, rows, or list items share the same structure, but only one contains the heading, status, badge, or control needed by the test. The reliable pattern is to locate the repeated container, filter each candidate by a relative product signal, and perform the action inside the surviving container.

The examples below are runnable Playwright Test cases. They cover text, regular expressions, has, negative conditions, visibility, frames, dynamic collections, locator composition, page objects, and failure diagnosis without invented APIs or fixed sleeps.

TL;DR

const orderRow = page.getByRole('row').filter({
  has: page.getByRole('cell', { name: 'QA-204', exact: true }),
  hasText: 'Ready',
});

await orderRow.getByRole('button', { name: 'Open' }).click();
Need Filter pattern Best assertion
Find a card by exact heading filter({ has: heading }) toHaveCount(1)
Find any container containing copy filter({ hasText: text }) Content or action result
Exclude a badge or state filter({ hasNot: badge }) Expected remaining count
Exclude broad descendant text filter({ hasNotText: text }) Positive state after filtering
Choose the currently displayed duplicate filter({ visible: true }) Action result or visibility
Work in an iframe Build outer and inner locators in the frame Frame-owned UI result

Use has when descendant semantics matter. Use hasText when text anywhere in the component is sufficient. Use position only when order itself is the requirement.

1. playwright locator filter examples by Scenario

A useful filter reads like a relationship in plain English:

  • The product card that has the heading "Trail Backpack".
  • The order row that has the exact cell "QA-204".
  • The project item that does not have an "Archived" badge.
  • The Save button that is currently visible.

This language maps directly to an outer Locator and one or more filter options.

Scenario Outer locator Filter Scoped next step
Product catalog getByRole('article') Named heading through has Add-to-cart button
Data table getByRole('row') Exact cell plus status Row action menu
Search results getByRole('listitem') Keyword regex Result link
Plan selector getByRole('article') Exclude unavailable badge Choose plan
Responsive toolbar locator('button') Text plus visible state Click current variant
Embedded order grid Frame-owned rows Frame-owned cell through has Open order

A Locator is a lazy query. filter() returns another Locator rather than an array, so it resolves against current DOM state when the assertion or action runs. A filter can still match zero or several candidates. Playwright's strictness remains a safety feature.

The conceptual Playwright locator filter guide explains option rules and relative scope. The recipes here emphasize how to turn those rules into maintainable tests.

2. Filter a Product Card by Its Heading

A card heading is usually stronger identity than full-card text because descriptions, prices, and button labels can change independently.

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

test('adds the product identified by its heading', async ({ page }) => {
  await page.setContent(`
    <main>
      <article>
        <h2>City Backpack</h2>
        <p>Compact daily pack</p>
        <button>Add to cart</button>
      </article>
      <article>
        <h2>Trail Backpack</h2>
        <p>Weather-ready hiking pack</p>
        <button>Add to cart</button>
      </article>
      <output>Cart empty</output>
    </main>
    <script>
      document.querySelectorAll("article").forEach(card => {
        card.querySelector("button").addEventListener("click", () => {
          document.querySelector("output").textContent =
            card.querySelector("h2").textContent + " added";
        });
      });
    </script>
  `);

  const card = page.getByRole('article').filter({
    has: page.getByRole('heading', {
      name: 'Trail Backpack',
      exact: true,
    }),
  });

  await expect(card).toHaveCount(1);
  await card.getByRole('button', { name: 'Add to cart' }).click();
  await expect(page.locator('output')).toHaveText('Trail Backpack added');
});

The inner heading locator is evaluated beneath each article. Even though it is constructed from page, Playwright treats it as relative when used in has.

A common weaker pattern is page.getByText('Trail Backpack').locator('..'). It depends on immediate DOM ancestry and can break when designers wrap the heading. Filtering at the known article boundary describes the component instead of the current nesting depth.

If the article receives an accessible name from its heading, page.getByRole('article', { name: 'Trail Backpack' }) may be even simpler. Use filter when the component boundary and descendant identity are separate in the rendered semantics.

3. Filter a Table Row by Exact Cell and Status

Rows often combine a unique identifier with a state. Use has for the exact identifier cell and a second condition for the required state.

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

test('opens the ready order', async ({ page }) => {
  await page.setContent(`
    <table>
      <thead>
        <tr><th>Order</th><th>Status</th><th>Action</th></tr>
      </thead>
      <tbody>
        <tr><td>QA-203</td><td>Processing</td><td><button>Open</button></td></tr>
        <tr><td>QA-204</td><td>Ready</td><td><button>Open</button></td></tr>
      </tbody>
    </table>
    <p role="status">No order opened</p>
    <script>
      document.querySelectorAll("tbody tr").forEach(row => {
        row.querySelector("button").addEventListener("click", () => {
          document.querySelector("[role=status]").textContent =
            "Opened " + row.cells[0].textContent;
        });
      });
    </script>
  `);

  const readyOrder = page.getByRole('row')
    .filter({
      has: page.getByRole('cell', { name: 'QA-204', exact: true }),
    })
    .filter({
      has: page.getByRole('cell', { name: 'Ready', exact: true }),
    });

  await expect(readyOrder).toHaveCount(1);
  await readyOrder.getByRole('button', { name: 'Open' }).click();

  await expect(page.getByRole('status')).toHaveText('Opened QA-204');
});

Two cell locators are more precise than hasText: 'QA-204 Ready' because table text can include spacing, responsive labels, or unrelated descendant copy. They also document which values belong to cells.

Do not use nth(1) to choose the second data row unless the test specifically verifies order. Sorting, filtering, or inserted data can move QA-204 while its identity remains stable. For deliberate position checks, consult the Playwright nth locator examples.

Use scenario-created unique IDs where possible. If test data can legitimately produce two rows with the same display ID, include tenant, timestamp, or another product field that establishes the intended row.

4. Match Text with Strings and Regular Expressions

A hasText string uses case-insensitive substring matching. This is convenient for stable fragments but can be broader than expected.

const cards = page.getByRole('article');

const playwrightCards = cards.filter({
  hasText: 'playwright',
});

const issueCard = cards.filter({
  hasText: /\bBUG-[0-9]{4}\b/,
});

Use a regular expression when the component contains a structured dynamic value. Boundaries matter. /BUG-[0-9]+/ accepts extra digits and surrounding characters; /\bBUG-[0-9]{4}\b/ represents a four-digit issue contract.

Here is a runnable search result example:

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

test('finds the result with the expected issue format', async ({ page }) => {
  await page.setContent(`
    <ul aria-label="Issues">
      <li><a href="/issues/BUG-1042">BUG-1042: Login retry</a></li>
      <li><a href="/issues/TASK-991">TASK-991: Update copy</a></li>
    </ul>
  `);

  const bugResult = page.getByRole('listitem').filter({
    hasText: /\bBUG-[0-9]{4}\b/,
  });

  await expect(bugResult).toHaveCount(1);
  await expect(bugResult.getByRole('link'))
    .toHaveAttribute('href', '/issues/BUG-1042');
});

When only a heading or cell should determine identity, use has with that descendant rather than searching all card text. A footer saying "Related to BUG-1042" could make an unrelated card match the broad regex.

Do not copy volatile marketing paragraphs into filters. Select a stable user-facing name, semantic state, or team-owned test ID. The best text filter represents a feature contract, not the current visual composition.

5. Combine Positive and Negative Conditions

Negative filters are helpful when a collection intentionally includes variants that must not be selected. Pair them with positive identity so the locator cannot accept any arbitrary nonmatching item.

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

test('chooses the available team plan', async ({ page }) => {
  await page.setContent(`
    <main>
      <article>
        <h2>Starter</h2><p>Available</p><button>Choose</button>
      </article>
      <article>
        <h2>Team</h2><p>Available</p><button>Choose</button>
      </article>
      <article>
        <h2>Team</h2><p>Legacy</p><button disabled>Unavailable</button>
      </article>
      <p role="status">No plan selected</p>
    </main>
    <script>
      document.querySelectorAll("article button:not([disabled])").forEach(button => {
        button.addEventListener("click", () => {
          const card = button.closest("article");
          document.querySelector("[role=status]").textContent =
            card.querySelector("h2").textContent + " selected";
        });
      });
    </script>
  `);

  const teamPlan = page.getByRole('article').filter({
    has: page.getByRole('heading', { name: 'Team', exact: true }),
    hasNot: page.getByText('Legacy', { exact: true }),
  });

  await expect(teamPlan).toHaveCount(1);
  await teamPlan.getByRole('button', { name: 'Choose' }).click();
  await expect(page.getByRole('status')).toHaveText('Team selected');
});

The positive heading identifies the plan, and hasNot removes the obsolete variant. If the available card has an explicit stable status, a positive has for "Available" may be even stronger.

hasNotText searches broadly through descendant text. hasNot can target a role, test ID, or exact text node. Prefer the structured version when the exclusion is a specific badge or control.

Before asserting absence, prove that content loaded. An empty collection satisfies many negative conditions. A test that checks "no Error badges" should first assert the expected cards or a loaded-state signal.

6. Use visible When Responsive Variants Coexist

Some applications render desktop and mobile controls at the same time and hide one with CSS. When both lack a better region identity, visibility can be a legitimate filter.

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

test('uses the visible responsive save action', async ({ page }) => {
  await page.setViewportSize({ width: 390, height: 844 });
  await page.setContent(`
    <style>
      .desktop { display: none; }
      @media (min-width: 800px) {
        .desktop { display: inline-block; }
        .mobile { display: none; }
      }
    </style>
    <button class="desktop">Save</button>
    <button class="mobile">Save</button>
    <p role="status">Unsaved</p>
    <script>
      document.querySelectorAll("button").forEach(button => {
        button.addEventListener("click", () => {
          document.querySelector("[role=status]").textContent = "Saved";
        });
      });
    </script>
  `);

  const save = page.locator('button').filter({
    hasText: 'Save',
    visible: true,
  });

  await expect(save).toHaveCount(1);
  await save.click();
  await expect(page.getByRole('status')).toHaveText('Saved');
});

This example intentionally tests the mobile viewport. If the desktop and mobile controls live in named navigation regions, scope by region instead. Semantic scope explains more than visibility alone.

Do not add visible: true whenever strictness reports duplicates. Inspect whether a hidden duplicate is a valid responsive implementation, an animation artifact, a stale modal, or a product bug. A filter should encode a known contract.

visible: false can be useful for asserting inactive panels remain hidden, but visibility state can change. Use web-first assertions when transition timing matters, and assert the product's active tab or dialog state as well.

7. Filter Nested Components Without Escaping Scope

Nested structures require the correct outer boundary. Choose the component that actually contains every signal needed by the relationship.

const billingSection = page.getByRole('region', { name: 'Billing' });

const failedInvoice = billingSection
  .getByRole('row')
  .filter({
    has: billingSection.getByRole('cell', {
      name: 'Payment failed',
      exact: true,
    }),
  });

Although the inner locator begins from billingSection in code, it must be valid when evaluated beneath each row. A cell is a descendant of the row, so the relationship works. An inner locator that starts by selecting the Billing region itself would not, because that region is an ancestor outside the row candidate.

For a card inside a list item, decide which boundary owns the action. If the action button is inside the card, filter cards. If selection state and action belong to the entire list item, filter list items. Avoid climbing with repeated locator('..').

const projectItem = page.getByRole('listitem').filter({
  has: page.getByRole('heading', { name: 'Migration' }),
});

await projectItem.getByRole('button', { name: 'Open actions' }).click();

When a component contains another repeated collection, scope the inner collection after choosing the parent. First select the project card, then locate its task rows. This avoids a page-wide filter accidentally relating a task in one project to a heading in another.

Readable scope is a major reason to use filter. Preserve it by keeping each relationship local and avoiding selectors that depend on distant ancestors.

8. Apply Filters Inside an iframe

Outer and inner locators used by has must be in the same frame. Enter the iframe first, then create both locators from that frame context.

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

test('opens a matching order inside an iframe', async ({ page }) => {
  await page.setContent(`
    <iframe title="Orders" srcdoc="
      <table>
        <tr><th>Order</th><th>Action</th></tr>
        <tr><td>QA-401</td><td><button>Open</button></td></tr>
        <tr><td>QA-402</td><td><button>Open</button></td></tr>
      </table>
      <p role='status'>Waiting</p>
      <script>
        document.querySelectorAll('tr').forEach(row => {
          const button = row.querySelector('button');
          if (button) button.addEventListener('click', () => {
            document.querySelector('[role=status]').textContent =
              'Opened ' + row.cells[0].textContent;
          });
        });
      </script>
    "></iframe>
  `);

  const orders = page.frameLocator('iframe[title="Orders"]');
  const order = orders.getByRole('row').filter({
    has: orders.getByRole('cell', { name: 'QA-402', exact: true }),
  });

  await order.getByRole('button', { name: 'Open' }).click();
  await expect(orders.getByRole('status')).toHaveText('Opened QA-402');
});

The inner locator cannot contain a FrameLocator, and a main-page outer locator cannot be filtered by content from an iframe. Frames are separate DOM documents. Model the boundary rather than trying to make one cross-document relationship.

If a top-page card owns the iframe, first choose the top-page card by its own heading or attribute, then use its iframe locator for work inside. Treat those as sequential scopes, not one has condition.

Frame content may navigate. Frame locators and regular locators remain lazy, which is safer than caching element handles. Assert a frame-owned outcome after the interaction to prove the correct embedded component responded.

9. Compose filter with and(), or(), and Descendant Locators

filter() models a container based on descendants. Other locator composition APIs model different relationships.

const enabledSave = page.getByRole('button', { name: 'Save' })
  .and(page.locator(':not([disabled])'));

const readyOrQueued = page.getByText('Ready', { exact: true })
  .or(page.getByText('Queued', { exact: true }));

const job = page.getByRole('article').filter({
  has: readyOrQueued,
});

and() intersects locators that describe the same element. or() combines alternatives and can match more than one element if both states exist. filter({ has }) describes an outer element containing another match. After selecting an outer element, a descendant locator identifies the action inside it.

Question Operation
Does this card contain a Ready state? filter({ has: ready })
Is this same button named Save and enabled by selector? and()
Can either a success or warning surface appear? or()
Which button belongs to the filtered card? card.getByRole('button')
Is the second element the required one? nth(1) only if order matters

Be careful when or() permits both alternatives. An action against the combined locator can produce a strictness error. Assert the expected state or narrow the result according to the scenario.

Do not build one massive composed locator merely because the API permits it. A named intermediate such as readyJobCard improves trace readability and lets the test assert important state before clicking.

10. Synchronize Dynamic Filter Results

A filtered locator reevaluates current DOM state, but a one-time method such as count() does not wait for future matches. Use retrying assertions for dynamic results.

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

test('waits for the matching deployment card', async ({ page }) => {
  await page.setContent(`
    <main aria-label="Deployments">
      <p role="status">Loading deployments</p>
    </main>
    <script>
      window.setTimeout(() => {
        document.querySelector("main").insertAdjacentHTML(
          "beforeend",
          "<article><h2>API service</h2><p>Ready</p></article>"
        );
        document.querySelector("[role=status]").textContent =
          "Deployments loaded";
      }, 100);
    </script>
  `);

  const deployment = page.getByRole('article').filter({
    has: page.getByRole('heading', { name: 'API service' }),
    hasText: 'Ready',
  });

  await expect(page.getByRole('status')).toHaveText('Deployments loaded');
  await expect(deployment).toHaveCount(1);
  await expect(deployment).toContainText('Ready');
});

There is no fixed timeout. The status proves the loading lifecycle completed, and toHaveCount(1) proves the desired relationship exists.

For absence checks, readiness is mandatory. await expect(cards.filter({ hasText: 'Failed' })).toHaveCount(0) can pass immediately before any cards render. Establish the expected baseline first.

Avoid locator.all() on a changing list. It returns the currently matched locators without waiting for stability. If iteration is necessary, assert a stable list state first, then obtain the items. Often a collective assertion or one filtered business target is clearer than manual iteration.

The Playwright auto-waiting guide provides additional patterns for state-based synchronization.

11. Put Domain Filters in Component Objects

A reusable component object can return filtered Locators while preserving scenario-specific assertions.

import { type Locator, type Page } from '@playwright/test';

export class OrdersTable {
  readonly rows: Locator;

  constructor(private readonly page: Page) {
    this.rows = page.getByRole('table', { name: 'Orders' })
      .getByRole('row');
  }

  order(orderNumber: string): Locator {
    return this.rows.filter({
      has: this.page.getByRole('cell', {
        name: orderNumber,
        exact: true,
      }),
    });
  }

  async open(orderNumber: string): Promise<void> {
    await this.order(orderNumber)
      .getByRole('button', { name: 'Open' })
      .click();
  }
}

Returning a Locator keeps lazy evaluation and lets callers add toHaveCount(1), status assertions, or other scenario-specific constraints. Avoid returning an ElementHandle or static array for a component that rerenders.

Name methods after domain identity, not Playwright syntax. order('QA-204') is more useful than filterRow('QA-204'). The implementation can evolve from a cell filter to a named row without changing test intent.

Do not hide every assertion inside the object. A universal postcondition such as "the details dialog opens" may belong in open(), while order-specific status remains in the test. Preserve Playwright's original errors and locator call logs.

Use exact cell matching when the ID must be unique. A substring such as QA-20 can match QA-203 and QA-204. Component helpers are widely reused, so small ambiguity becomes a suite-wide reliability problem.

12. Debug and Review playwright locator filter examples

When a filtered locator fails, inspect each stage separately:

const rows = page.getByRole('row');
await expect(rows).toHaveCount(4);

const matching = rows.filter({
  has: page.getByRole('cell', { name: 'QA-204', exact: true }),
});
await expect(matching).toHaveCount(1);

Use those assertions temporarily or keep them when counts represent real requirements. Run with Playwright Inspector:

npx playwright test tests/orders.spec.ts --debug

Review the DOM snapshot and locator highlights. Ask whether the desired descendant is actually beneath the outer candidate, whether several responsive copies exist, and whether text changed after localization or hydration.

Before merge, check:

  1. The outer locator is a real component boundary.
  2. String text matching is intentionally substring-based.
  3. Regex patterns are bounded to the required format.
  4. has and hasNot locators remain relative and in the same frame.
  5. Negative filters cannot accept unknown states accidentally.
  6. Visibility represents a product rule, not a hidden defect.
  7. Dynamic absence checks establish loaded state.
  8. first() or nth() appears only for order requirements.
  9. The final assertion proves the business outcome.
  10. Helpers return Locators and retain semantic naming.

A strictness error is often the best possible failure. It tells you that the product or test data no longer gives the action a unique identity. Strengthen that contract instead of selecting an arbitrary match.

Interview Questions and Answers

Q: How would you locate a product card by its heading?

I would start with the article collection and call filter({ has: page.getByRole('heading', { name, exact: true }) }). Then I would scope the card action beneath that result. This preserves the card-to-heading relationship and avoids positional selection.

Q: Why use has instead of hasText for a table identifier?

has can require an exact cell locator, which proves the identifier appears in the expected semantic column. hasText searches all descendant text and may match notes or actions elsewhere in the row.

Q: How do you safely use negative filters?

I pair the exclusion with positive identity and prefer a required positive state when available. I also establish that the collection loaded before asserting absence. This avoids empty-page false positives and unexpected new states.

Q: When is visible filtering justified?

It is justified when the product intentionally keeps multiple variants in the DOM and visible state determines the active one. I first look for semantic region scope and investigate duplicates before using visibility.

Q: Can has filter content across frames?

No. Outer and inner locators must be in the same frame, and the inner locator cannot contain a FrameLocator. I enter the frame first and create both locators there.

Q: How do you wait for a dynamic filtered result?

I assert a loaded-state signal and use a retrying locator assertion such as toHaveCount(1) or toContainText(). I do not take an immediate count or add a fixed sleep.

Q: How is filter different from and()?

filter() keeps an outer element based on descendants or visibility. and() intersects two locators that describe the same element. I choose based on the relationship the product exposes.

Common Mistakes

  • Filtering page-wide div elements instead of a meaningful repeated component.
  • Using substring hasText for an ID that requires exact cell or heading identity.
  • Selecting a table row by index even though a stable order number exists.
  • Passing a locator through has that depends on an ancestor outside the candidate.
  • Crossing a frame boundary with outer and inner locators.
  • Using a negative condition without positive identity.
  • Checking that filtered failures equal zero before data finishes loading.
  • Adding visible: true to silence strictness without explaining the duplicate.
  • Assuming locator count() retries like toHaveCount().
  • Combining or() alternatives and forgetting that both may match.
  • Returning static elements from a page object for a rerendering list.
  • Asserting only that a click completed, not that the intended component responded.

Translate a failing locator back into English. If the sentence cannot identify the container and its required descendant without referring to DOM position, the test probably needs a clearer product signal.

Conclusion

The best playwright locator filter examples select repeated components through meaningful relationships. Start with a semantic card, row, or list item, filter by an exact descendant when structure matters, use broad text and negative conditions intentionally, and keep frame boundaries explicit.

Assert loaded state and uniqueness where required, then scope the action inside the chosen component and verify its user-visible result. That pattern produces locators that survive sorting, wrapper changes, and dynamic rendering while still failing clearly when product identity becomes ambiguous.

Interview Questions and Answers

How do you locate a repeated card by heading?

I locate the card collection and filter it with an exact heading passed through has. I assert uniqueness when it is a business rule, then scope the intended action inside the card.

When is hasText weaker than has?

hasText searches all descendant text, so a phrase in a description or footer can match accidentally. has can require a heading, cell, badge, or other semantic descendant in the intended structure.

How do you use negative filters safely?

I combine them with positive identity, prefer a positive required state when available, and verify data loaded before checking absence. That prevents empty collections and unknown states from passing.

How do you handle responsive duplicate controls?

I first scope to a semantic region if possible. If both variants intentionally coexist without better identity, I filter by visible state and verify the viewport-specific outcome.

How do filters work in iframes?

The outer and inner locators must belong to the same frame. I create a FrameLocator, build both locators from that frame, and keep interaction and assertion within it.

How do you synchronize filtered dynamic results?

I wait for a meaningful loaded-state signal and use locator assertions such as toHaveCount() or toContainText(). I avoid immediate count snapshots and fixed sleeps.

What do you inspect when a filtered locator is not unique?

I check the outer component boundary, substring breadth, hidden variants, test data duplicates, and the exact descendant relationship. I add semantic scope rather than first() unless order is the requirement.

Frequently Asked Questions

How do I filter a Playwright card by heading?

Start with page.getByRole('article') and pass an exact heading locator through filter({ has }). Then locate the card's button or link beneath the filtered result.

How do I filter a Playwright table row by cell text?

Filter the row collection with has set to an exact cell locator. This is more precise than searching all row text when the column meaning matters.

Can hasText use a regular expression?

Yes. Pass a JavaScript RegExp to hasText. Use anchors or boundaries that represent the required format so unrelated descendant copy does not create extra matches.

How do I exclude archived elements in Playwright?

Use hasNot with a structured Archived badge locator or hasNotText when broad text absence is sufficient. Pair the exclusion with positive identity and prove the list loaded first.

What is a Playwright filter visible example?

Use page.locator('button').filter({ hasText: 'Save', visible: true }) when responsive variants intentionally coexist. Investigate duplicates and prefer semantic region scope when available.

Does locator.filter wait for dynamic content?

The locator stays lazy, but snapshot methods such as count() do not wait for a future business state. Use retrying assertions such as toHaveCount() after establishing page readiness.

How should page objects expose filtered elements?

Return a Locator from a domain-named method such as order(orderNumber). This preserves lazy resolution and allows scenario-specific assertions at the call site.

Related Guides