Why is Playwright getByTestId not locating my element while `locator()` works?

I’m running into an issue with the Playwright getByTestId method. I’m trying to target an input field that uses data-testid="createTitle", but when I use:

await this.page.getByTestId("createTitle")

The test times out and throws the following error:

Test timeout of 30000ms exceeded.
Error: locator.fill: Test timeout of 30000ms exceeded.

What’s odd is that using this alternative does work:

await this.page.locator('[data-testid="createTitle"]').fill(title)

Since both approaches should target the same element, I’m not sure why getByTestId fails while locator() works.

The tag is definitely present, and even VS Code’s Playwright extension confirms it with the exact selector.

I’d really like to stick with Playwright getByTestId for consistency across my codebase.

Has anyone experienced this inconsistency or figured out what causes it to fail in certain scenarios?

Would appreciate any insights or workarounds!

This sounds familiar, I’ve hit a similar wall with getByTestId in Playwright before.

If you’re using getByTestId directly on this.page, make sure your project is actually using the @playwright/test library, which includes those built-in role/query helpers.

If you’re using raw Playwright (no test runner), getByTestId won’t be available out of the box.

In that case, the method will silently fail or never resolve , leading to the timeout you’re seeing.

Fix: Ensure you’re importing from @playwright/test and using the test context like so:

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

test('input should be filled', async ({ page }) => {
  await page.getByTestId('createTitle').fill('My Title');
});

If you’re outside this context (like a custom helper class), consider injecting the test’s page object.

This should help you @sakshikuchroo

Just to add to @dimplesaini.230 solution i think that one sneaky reason why getByTestId() fails but locator() works is if the element is nested inside an iframe or a shadow DOM. locator() gives you more control to dive into nested trees, but getByTestId() might fail silently in those scopes unless you explicitly scope it.

Try scoping manually using frameLocator or inside the shadow root:

const frame = page.frameLocator('#my-frame');
await frame.getByTestId('createTitle').fill('My Title');

Or if it’s a web component:

await page.locator('my-component').shadowRoot().getByTestId('createTitle').fill('My Title');

This subtle context boundary often explains why the two methods behave differently.

Like @devan-skeem and @dimplesaini.230 pointed out, my issue also came down to visibility.

I ran into a nearly identical problem where getByTestId("createTitle") timed out even though the element existed in the DOM. The catch? It wasn’t visible yet, hidden briefly due to a CSS transition or a delayed mount.

What worked for me was explicitly waiting for visibility:

await this.page.getByTestId("createTitle").waitFor({ state: "visible" });
await this.page.getByTestId("createTitle").fill(title);

Once I added the visibility check, everything worked as expected. So if locator('[data-testid="createTitle"]') works but getByTestId() doesn’t, definitely double-check if the element is actually visible when the call is made.

Visibility is key here. :eyes::white_check_mark: