Resource library

QA How-To

How to Fix Playwright expect received value must be a Locator

Learn how to fix Playwright expect received value must be a Locator by matching locator assertions, value assertions, async waits, and reliable test intent.

23 min read | 2,747 words

TL;DR

To fix Playwright expect received value must be a Locator, pass the Locator itself to a locator matcher: `await expect(page.getByRole('button')).toBeVisible()`. If you intentionally extracted a string, number, boolean, or object, use a generic matcher such as `expect(text).toContain('Saved')`, or use `expect.poll` when the value must eventually change.

Key Takeaways

  • Pass a Locator directly to web-first matchers such as toBeVisible, toHaveText, and toHaveCount.
  • Do not await textContent, isVisible, or count before a locator assertion because that converts a retryable locator into a snapshot value.
  • Use generic Jest-style matchers for strings, numbers, booleans, arrays, and plain objects.
  • Use expect.poll when a non-DOM value must be sampled until it reaches the expected state.
  • Prefer locator assertions because they re-resolve the element and retry during the assertion timeout.
  • Keep Locator, Page, APIResponse, and plain-value matcher families aligned with the object under test.
  • Fix the assertion boundary instead of hiding the type mismatch with casts, force options, sleeps, or retries.

To fix playwright expect received value must be a Locator, stop passing an extracted string, boolean, element handle, or promise result to a locator-only matcher. Pass the Playwright Locator directly to matchers such as toBeVisible() and toHaveText(), or switch to a generic matcher when you truly want to assert a plain value.

This error is usually one line away from the real problem. An early await often changes a live, retryable locator into a one-time snapshot. The correct repair depends on what the test is trying to prove: DOM state, page state, an HTTP response, or an application value. This guide gives you a precise decision process, runnable TypeScript examples, and patterns that remain stable under real UI timing.

TL;DR

Value passed to expect Correct matcher style Example
Locator Web-first locator assertion await expect(locator).toHaveText('Ready')
Page Page assertion await expect(page).toHaveURL(/dashboard/)
APIResponse Response assertion await expect(response).toBeOK()
String, number, boolean, array, object Generic value assertion expect(total).toBe(3)
Eventually changing non-DOM value Polling assertion await expect.poll(readStatus).toBe('ready')
Callback containing several retryable checks Retrying block await expect(async () => { ... }).toPass()

The most common incorrect and correct forms are:

// Wrong: textContent() returns string | null, not Locator.
await expect(await page.getByTestId('status').textContent()).toHaveText('Ready');

// Correct: keep the Locator and use its retryable assertion.
await expect(page.getByTestId('status')).toHaveText('Ready');

1. What the Error Actually Means

Playwright Test includes several matcher families. Some matchers operate on ordinary JavaScript values. Others are asynchronous, web-first assertions that require a Playwright object such as a Locator, Page, or APIResponse. The message appears when the selected matcher belongs to one family but the received object belongs to another.

For example, toBeVisible() is a locator assertion. It needs a Locator because Playwright repeatedly resolves that locator and checks visibility until the assertion passes or its timeout expires. A boolean returned by locator.isVisible() has no selector, page, or retry behavior. Passing that boolean to toBeVisible() therefore cannot work.

The error is a contract failure, not evidence that the element is hidden. Read the matcher name and inspect the exact expression inside expect(...). Log its type only if the source is unclear. In TypeScript, hover over the expression or assign it to a meaningfully typed variable. Methods such as textContent(), innerText(), inputValue(), count(), isVisible(), and evaluate() return values. The locator methods getByRole(), getByText(), getByTestId(), locator(), filter(), first(), and nth() return locators.

This classification is the foundation. Once you know the received type and the behavior you want, the correct matcher is usually obvious.

2. Locator Assertions Versus Value Assertions

Locator assertions and value assertions can test similar facts, but they do not have the same timing model. A locator assertion keeps the query alive. A generic assertion checks the value already captured in memory.

Goal Preferred assertion Snapshot alternative Main tradeoff
Element becomes visible await expect(locator).toBeVisible() expect(await locator.isVisible()).toBe(true) Alternative checks once
Text eventually becomes Saved await expect(locator).toHaveText('Saved') expect(await locator.textContent()).toBe('Saved') Alternative can capture loading text
Input receives a value await expect(locator).toHaveValue('QA') expect(await locator.inputValue()).toBe('QA') Alternative has no automatic retry
List reaches five rows await expect(rows).toHaveCount(5) expect(await rows.count()).toBe(5) Alternative samples one instant
Attribute appears await expect(locator).toHaveAttribute('aria-busy', 'false') Assert result of getAttribute() Alternative may see null too early
Pure calculation equals five expect(total).toBe(5) Not applicable No DOM retry is needed

A snapshot assertion is not always wrong. If the test intentionally samples a moment, or if the value is immutable after an awaited operation, a generic matcher is suitable. The mistake is combining an extracted value with a matcher that requires a locator.

When testing dynamic UI, prefer the locator form. It provides clearer failure output, expected and received state, call logs, and automatic retry. The Playwright getByRole locator guide explains how user-facing locators improve both resilience and error messages.

3. Fastest Way to Fix Playwright expect received value must be a Locator

Start by removing the value extraction from inside expect. Keep the locator and choose the assertion that describes the visible contract.

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

test('shows a saved confirmation', async ({ page }) => {
  await page.setContent(`
    <button type="button" id="save">Save</button>
    <p data-testid="status" hidden>Saved</p>
    <script>
      document.querySelector('#save').addEventListener('click', () => {
        setTimeout(() => {
          document.querySelector('[data-testid="status"]').hidden = false;
        }, 100);
      });
    </script>
  `);

  const status = page.getByTestId('status');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(status).toBeVisible();
  await expect(status).toHaveText('Saved');
});

In real code, TypeScript may already report the mismatch. Do not silence it with as any or as Locator. A cast changes the compiler's opinion, not the runtime object. Preserve the concrete type and correct the API boundary.

If the original assertion receives a value on purpose, change the matcher instead:

const label = await page.getByTestId('status').textContent();
expect(label).toContain('Saved');

That second form checks a snapshot. It is correct only when timing has already been established or the immediate value is the subject of the test.

4. Remove the Premature await Pattern

A frequent cause is not await itself, but where it is placed. Playwright locators are created synchronously. Their actions and asynchronous assertions are awaited later. Compare these patterns:

const submit = page.getByRole('button', { name: 'Submit' });

// Wrong matcher and wrong received type.
await expect(await submit.isVisible()).toBeVisible();

// Correct locator assertion.
await expect(submit).toBeVisible();

// Valid snapshot assertion, but it does not wait for visibility to change.
expect(await submit.isVisible()).toBe(true);

The same issue affects text, values, counts, and attributes:

const rows = page.getByRole('row');

await expect(rows).toHaveCount(4);
await expect(page.getByLabel('Email')).toHaveValue('qa@example.com');
await expect(page.getByRole('alert')).toContainText('Success');

Do not write await page.getByRole(...) either. Awaiting a non-promise value currently returns that value unchanged in JavaScript, so it may appear harmless, but it communicates the wrong model and can hide the important boundary. Create a locator synchronously, then await actions and assertions.

This also improves reviewability. A reader can see that status represents a query, not an already located DOM node. That mental model helps prevent stale element assumptions and supports Playwright's auto-waiting behavior. For broader synchronization diagnosis, see the Playwright timeout troubleshooting guide.

5. Choose the Correct Matcher for Strings, Numbers, and Booleans

When the subject is a plain JavaScript value, use generic matchers. These matchers are synchronous unless their received value or special API says otherwise.

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

test('validates calculated order data', async ({ page }) => {
  await page.setContent(`
    <ul><li>Mouse</li><li>Keyboard</li></ul>
    <p data-testid="subtotal">125.50</p>
  `);

  const itemNames = await page.getByRole('listitem').allTextContents();
  const subtotalText = await page.getByTestId('subtotal').textContent();
  const subtotal = Number(subtotalText);

  expect(itemNames).toEqual(['Mouse', 'Keyboard']);
  expect(subtotal).toBeCloseTo(125.5, 2);
  expect(Number.isFinite(subtotal)).toBe(true);
});

Generic matchers include toBe for identity and primitives, toEqual for recursive equality, toContain for strings and arrays, toMatch for strings, and numeric comparisons such as toBeGreaterThan. They should not be replaced with locator matchers just because the value originated in the page.

Be deliberate about nullability. textContent() returns string | null. If null would indicate a real failure, assert it explicitly or use a locator assertion that expresses the DOM contract:

const text = await page.getByTestId('status').textContent();
expect(text).not.toBeNull();
expect(text).toContain('Saved');

Do not use a non-null assertion solely to quiet TypeScript. It can turn a useful failure into a less clear runtime exception.

6. Use Web-First Assertions for Dynamic UI

Web-first assertions are more than convenient syntax. They repeatedly query the target and evaluate the condition within the configured assertion timeout. This behavior is why the received object must remain a Locator.

Consider a status that changes from Queued to Processing to Complete. Extracting text immediately can capture any intermediate state. A locator assertion waits for the expected user-visible state:

const status = page.getByRole('status');
await expect(status).toHaveText('Complete', { timeout: 10_000 });

Use the most specific semantic assertion available. toHaveText tests normalized element text, toContainText allows a substring or subsequence, toHaveValue targets form control values, toBeChecked targets checkbox or radio state, and toHaveAccessibleName tests the computed accessible name. Specific assertions produce better diagnostics than a broad evaluate() followed by a generic comparison.

Keep timeout changes local to genuinely slow business operations. Raising every assertion timeout can hide a missing event or wrong locator. First confirm the action that triggers the state was awaited and the locator identifies the right element.

Negative locator assertions are also retryable:

await expect(page.getByRole('progressbar')).toBeHidden();
await expect(page.getByRole('alert')).not.toContainText('Failed');

Remember that a negative assertion can pass immediately if the locator never finds an element. Decide whether absence, hidden state, and removed state all satisfy the business requirement.

7. Fix Helper Functions That Return the Wrong Type

Page objects and utility functions often cause the mismatch indirectly. A helper named status may return text while the caller assumes it returns a locator. Make the return type and name explicit.

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

export class CheckoutPage {
  constructor(private readonly page: Page) {}

  status(): Locator {
    return this.page.getByTestId('checkout-status');
  }

  async statusText(): Promise<string> {
    return (await this.status().textContent()) ?? '';
  }
}

Both methods are valid, but their callers should use different assertions:

const checkout = new CheckoutPage(page);
await expect(checkout.status()).toHaveText('Paid');
expect(await checkout.statusText()).toBe('Paid');

Prefer returning locators from page-object element accessors. This leaves the caller free to click, fill, inspect, or use a retryable assertion. Return a value from a method whose purpose is business data extraction, and name it accordingly.

Avoid storing ElementHandle objects for routine test flows. Locators are re-evaluated and are the primary Playwright abstraction for actions and assertions. If a third-party helper returns an element handle, do not cast it to a locator. Change the helper to return a locator, locate the same element through Playwright, or use an appropriate value assertion for the data you extract.

TypeScript return annotations are valuable here. They prevent an innocent refactor from changing a locator accessor into a promise of text without updating its consumers.

8. Handle Promises and Async Data Without Losing Retry Behavior

A promise is not a locator either. If a helper resolves to a locator, await the helper before passing the result to expect. Better yet, avoid an asynchronous locator factory when no asynchronous work is required.

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

function successAlert(page: Page): Locator {
  return page.getByRole('alert').filter({ hasText: 'Success' });
}

await expect(successAlert(page)).toBeVisible();

For non-DOM data that changes over time, use expect.poll. It repeatedly invokes a function and passes its latest return value to a generic matcher:

await expect.poll(
  async () => {
    const response = await page.request.get('/api/jobs/42');
    expect(response.ok()).toBe(true);
    const data = (await response.json()) as { status: string };
    return data.status;
  },
  {
    message: 'job 42 should complete',
    timeout: 15_000,
    intervals: [250, 500, 1_000],
  },
).toBe('complete');

This is different from passing a promise to toHaveText. The callback intentionally returns a plain value, and expect.poll supplies the retry loop. Use it for API state, database projections exposed through a test helper, queue status, or other eventually consistent non-DOM conditions.

Keep the polling callback idempotent and reasonably cheap. It may run multiple times. Do not put destructive actions inside it unless repetition is explicitly safe.

9. Distinguish Locator, Page, and APIResponse Assertions

Not every Playwright-specific matcher expects a locator. Some expect a Page or APIResponse. The same debugging technique still applies: match the object to the matcher family.

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

test('checks page and API contracts', async ({ page, request }) => {
  await page.goto('https://example.com/');
  await expect(page).toHaveTitle(/Example Domain/);
  await expect(page).toHaveURL('https://example.com/');

  const response = await request.get('https://example.com/');
  await expect(response).toBeOK();
});

Do not extract page.url() and then call toHaveURL. page.url() returns a string, while toHaveURL is a page assertion. Either preserve the Page:

await expect(page).toHaveURL(/dashboard/);

Or intentionally assert the snapshot:

expect(page.url()).toContain('/dashboard');

Likewise, response.status() is a number, so expect(response.status()).toBe(204) is valid. toBeOK() expects the response object. The Playwright-specific form often provides richer output and clearer intent, but exact status assertions are appropriate when the endpoint contract requires a particular code.

When a failure says the received value is the wrong type, inspect the matcher documentation and TypeScript signature. The name alone may not reveal whether it expects a locator, page, response, or ordinary value.

10. Use toPass for Multi-Step Eventually Consistent Checks

Sometimes one condition spans several values or systems. expect(async () => { ... }).toPass() retries the entire callback until it completes without an assertion error. Inside the callback, use generic matchers for extracted values and await any async operations.

await expect(async () => {
  const response = await page.request.get('/api/orders/77');
  expect(response.status()).toBe(200);

  const order = (await response.json()) as {
    status: string;
    auditEntries: number;
  };
  expect(order.status).toBe('shipped');
  expect(order.auditEntries).toBeGreaterThan(0);
}).toPass({
  timeout: 20_000,
  intervals: [500, 1_000, 2_000],
});

Use this when the callback represents one eventual business invariant. For a single scalar return value, expect.poll is usually simpler. For a DOM condition, a direct locator assertion is usually clearer and includes Playwright's actionability-aware call log.

Do not wrap every flaky block in toPass. Retrying an action that creates data can duplicate side effects. Retrying a broad block can also mask which event is missing. Keep the callback read-only when possible and narrow it to the state that is expected to converge.

Also note that toPass has its own timeout configuration. Make the budget explicit when the system is expected to take longer than the suite's normal assertion interval. A clear message and small callback make the final failure useful.

11. Diagnose JavaScript and TypeScript Edge Cases

Runtime errors can survive TypeScript when the value crosses an untyped boundary. Common sources include any, JSON configuration, custom wrappers, JavaScript test files, and helpers that return unions. Add types at the boundary instead of casting at the assertion.

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

function expectVisible(target: Locator): Promise<void> {
  return expect(target).toBeVisible();
}

This wrapper accepts only a locator. However, do not create wrappers for every matcher. Native Playwright assertions are familiar to the team and expose complete options.

Watch for arrays. locator.all() returns Promise<Locator[]>. The array itself cannot be passed to toBeVisible. Assert the collection through the original multi-element locator, or select and assert individual locators:

const cards = page.getByTestId('result-card');
await expect(cards).toHaveCount(3);

for (const card of await cards.all()) {
  await expect(card).toBeVisible();
}

Use all() only after the list is known to be stable because it does not wait for elements to appear. For most collection contracts, toHaveCount and toHaveText on the list locator are better.

Finally, verify imports. Tests should normally import expect from @playwright/test. A generic assertion library may have matchers with similar names but different received types and retry behavior.

12. Prevent and Fix Playwright expect received value must be a Locator at Scale

Make assertion intent visible in coding standards. Element accessors return Locator. Data accessors return explicitly named values. Dynamic DOM expectations use web-first assertions. Eventually consistent external state uses polling. Pure calculations use generic matchers.

A small review checklist prevents most occurrences:

  1. What is the static type inside expect(...)?
  2. Does the chosen matcher accept that type?
  3. Does the state change after the triggering action?
  4. If it changes, which layer should own the retry?
  5. Is the test asserting a user-visible contract or an implementation detail?

Use linting and TypeScript strictness to expose accidental any values, floating promises, and missing awaits. Do not depend on lint rules alone because this mistake can be semantically wrong while still compiling, particularly when a generic assertion is used on an immediate snapshot.

During migration from Selenium, avoid helpers that eagerly call textContent() or visibility checks. Playwright's locator model is designed to defer DOM resolution. Preserve that design through page objects and fixtures.

Pair this guidance with a locator strategy. Role, label, placeholder, text, and intentional test ID locators are easier to read and maintain than deep CSS. The Playwright getByTestId guide covers stable test contracts when user-facing attributes are not sufficient.

Interview Questions and Answers

Q: Why does expect(await locator.isVisible()).toBeVisible() fail?

isVisible() resolves to a boolean, but toBeVisible() requires a Locator so it can retry and report DOM state. Use await expect(locator).toBeVisible(). If an immediate snapshot is intentional, use expect(await locator.isVisible()).toBe(true).

Q: What is a web-first assertion?

It is a Playwright assertion that repeatedly resolves a page object and evaluates a browser-facing condition until it passes or times out. Locator assertions such as toHaveText and toBeVisible reduce timing races and provide action logs.

Q: Is expect(await locator.textContent()).toBe('Done') invalid?

No, it is a valid generic assertion on a string | null snapshot. It does not retry the DOM query, so await expect(locator).toHaveText('Done') is usually better for dynamic UI.

Q: When should you use expect.poll?

Use it when a non-DOM value must eventually reach an expected state. Examples include an API job status, a cache projection, or a read-only backend query.

Q: How do you design page-object getters for assertions?

Return Locator objects from element getters so callers retain actions and web-first assertions. Return plain values only from clearly named data methods such as statusText(), with explicit return types.

Q: Can a TypeScript cast fix the received-value error?

No. A cast has no runtime effect. It can suppress the compiler while leaving a string or boolean where Playwright needs a locator, making the eventual error harder to understand.

Q: What is the difference between toHaveCount and asserting locator.count()?

toHaveCount retries against the locator until the expected count is reached. count() returns the count at one moment, so a generic equality assertion checks only that snapshot.

Q: How would you debug the error in an unfamiliar framework?

I would inspect the matcher signature, determine the runtime and static type inside expect, and trace helper return types. Then I would decide whether the test needs a locator assertion, page or response assertion, generic value assertion, or polling assertion.

Common Mistakes

  • Passing the result of textContent(), innerText(), inputValue(), or count() to a locator matcher.
  • Writing expect(await locator.isVisible()).toBeVisible() instead of preserving the locator.
  • Adding as Locator or as any to hide a type mismatch.
  • Returning strings from page-object accessors whose names imply locators.
  • Using a one-time value assertion for UI that changes asynchronously.
  • Passing a Page to a locator matcher or a URL string to toHaveURL.
  • Passing an array returned by locator.all() to a single-locator matcher.
  • Wrapping a destructive action in expect.poll or toPass.
  • Increasing timeouts before confirming the matcher received the correct object.
  • Importing expect from a different assertion library and assuming Playwright retry behavior.
  • Replacing a clear locator assertion with arbitrary sleeps and snapshot checks.
  • Using broad custom wrappers that erase useful TypeScript types.

Conclusion

To fix playwright expect received value must be a Locator, align the matcher with the received object. Keep a Locator intact for web-first DOM assertions, pass a Page or APIResponse to its matching Playwright assertion, and use generic matchers for already extracted JavaScript values.

Then choose the correct retry boundary. Dynamic UI belongs in locator assertions, eventually changing backend data belongs in expect.poll or a narrow toPass block, and stable calculations need no retry. Review the expression inside expect(...), remove premature extraction, and run the focused test to confirm the assertion now communicates the intended contract.

Interview Questions and Answers

What causes Playwright's received value must be a Locator error?

A locator-only matcher received another runtime type, commonly a string, boolean, number, element handle, array, or promise result. The fix is to preserve the Locator for a web-first assertion or choose a matcher designed for the extracted value. I confirm the received type before changing the selector.

Why are locator assertions more reliable than immediate DOM value checks?

A locator assertion re-resolves the element and retries the condition during its timeout. An immediate check captures one moment, which can fall between an action and the eventual UI update. That retry window removes common UI timing races.

How would you correct expect await status textContent toHaveText Ready?

I would write `await expect(status).toHaveText('Ready')`. `textContent()` returns `string | null`, so it cannot be the received object for `toHaveText`. The locator form also keeps Playwright's automatic retry behavior.

When is a generic value assertion preferable?

It is preferable for pure calculations, parsed API data, immutable snapshots, and values whose timing has already been established. The matcher should state the data contract without pretending the value is a browser locator. The captured value should be stable at the assertion boundary.

What role does expect.poll play in this problem?

It supplies retry behavior for non-DOM values. The callback returns the latest string, number, boolean, or object property, and a generic matcher evaluates that result until it passes or times out. The polling callback should remain idempotent.

How should a page object expose elements?

Element accessors should normally return typed Locator objects. Methods that extract data should have names and return types that make the snapshot explicit, such as `statusText(): Promise<string>`. This preserves web-first assertions for callers.

Why is casting a boolean to Locator not a solution?

TypeScript casts do not transform runtime values. Playwright still receives a boolean with no selector, page, or retry mechanism, so the test fails later with less trustworthy type safety. The runtime contract remains unchanged.

How do toHaveCount and count plus toBe differ?

`toHaveCount` is an asynchronous locator assertion that retries until the list reaches the expected size. `count()` returns one numeric snapshot, and `toBe` compares that number only once. The expected timing model determines which form is correct.

Frequently Asked Questions

How do I fix Playwright expect received value must be a Locator?

Pass the Locator directly to a locator matcher, for example `await expect(page.getByRole('button')).toBeVisible()`. If you extracted a plain value intentionally, switch to a generic matcher that accepts that value.

Why does expect await locator isVisible toBeVisible fail?

`locator.isVisible()` returns a boolean immediately, while `toBeVisible()` requires a Locator for retrying. Use `await expect(locator).toBeVisible()`, or use `expect(await locator.isVisible()).toBe(true)` only for an intentional snapshot.

Should I use toHaveText or textContent with toBe?

Use `toHaveText` for dynamic UI because it retries against the locator and produces useful diagnostics. Use `textContent()` with a generic matcher when you specifically need the captured string or have already established timing.

Can Playwright expect receive an ElementHandle?

Locator assertions are designed for Locator objects, not ElementHandle objects. Prefer locating the element with a Locator, which re-resolves the DOM and supports Playwright's web-first assertions.

What values can I pass to Playwright toBeVisible?

Pass a Playwright Locator. A string, boolean, array of locators, element handle, or result of `textContent()` does not provide the locator behavior required by `toBeVisible()`.

How do I retry an assertion on an API value in Playwright?

Use `expect.poll` with an async callback that reads the current value, then chain a generic matcher. Keep the callback idempotent because Playwright may invoke it several times.

Does awaiting a locator turn it into an element?

No. Locator creation is synchronous, and awaiting a non-promise locator normally returns it unchanged. The harmful pattern is awaiting methods such as `textContent()` or `isVisible()` before choosing a locator-only matcher.

Related Guides