
A test should assert what a person can actually see, not the implementation detail that happened to produce it. If the page tells the user “Your changes were saved,” verify that message—not a hidden flag, a CSS class, or a particular framework’s internal state.
await expect(page.getByText("Your changes were saved")).toBeVisible();
Prefer locators that describe the interface in the same terms a user would use. A role and accessible name are usually more durable than a selector tied to markup structure:
await expect(
page.getByRole("button", { name: "Save changes" })
).toBeVisible();
Exact text is useful when the whole message matters. When only a meaningful phrase matters, a regular expression can allow harmless wording around it without making the assertion vague:
await expect(
page.getByRole("status")
).toContainText("saved");
Be careful with text that appears more than once. A broad text locator can match a navigation link, a hidden dialog, and the page content all at once. Scope the assertion to the region the user is looking at:
const dialog = page.getByRole("dialog", { name: "Confirm deletion" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("This action cannot be undone.")).toBeVisible();
And distinguish “present in the DOM” from “visible on the page.” Hidden templates and collapsed panels can contain the right words while showing none of them to the user. Use visibility assertions when visibility is the behavior that matters.
Now loading...
Choose between text and value
Text inside an input is not the same thing as text in the page. A textbox may look full to the user while its value lives in the element’s value property, not its text content. Use a value assertion for form controls:
const email = page.getByRole("textbox", { name: "Email address" });
await expect(email).toHaveValue("[email protected]");
For a message, label, or other rendered content, assert the text instead:
await expect(page.getByRole("status")).toHaveText("Email address updated");
Labels make form locators both readable and resilient. They also avoid depending on generated IDs or the order of inputs in the markup:
const quantity = page.getByLabel("Quantity");
await expect(quantity).toHaveValue("3");
Use toHaveValue for textboxes, textareas, and selected form values. For a checkbox or radio button, assert its checked state; for a button or paragraph, assert its text. Matching the assertion to the control’s actual behavior makes failures easier to understand:
await expect(page.getByLabel("Remember me")).toBeChecked();
await expect(page.getByRole("button", { name: "Continue" }))
.toHaveText("Continue");
A placeholder is neither a label nor a value. It disappears as soon as the user enters text, so asserting it does not tell you whether the form contains the expected input:
const search = page.getByRole("textbox", { name: "Search" });
await search.fill("playwright");
await expect(search).toHaveValue("playwright");
For a select control, the displayed option is often what matters to the user, while the underlying option value may be an internal identifier. Assert the visible label when that is the behavior under test:
const country = page.getByLabel("Country");
await country.selectOption({ label: "Canada" });
await expect(country).toHaveValue("ca");
If the interaction itself should select Canada, verify the selected option’s visible text as well, rather than relying only on a stored value that a user never sees
Make element assertions precise
Precision starts with identifying one element, not merely finding something that resembles it. If a page should have one Save button, assert that the accessible locator matches exactly one, then check its state:
const saveButton = page.getByRole("button", {
name: "Save",
exact: true
});
await expect(saveButton).toHaveCount(1);
await expect(saveButton).toBeEnabled();
Without an exact name match, a locator for “Save” may also match “Save as draft” or “Save changes.” That can make a test pass against the wrong control, especially as the interface grows.
const saveButton = page.getByRole("button", {
name: "Save changes",
exact: true
});
await expect(saveButton).toBeVisible();
For repeated elements, scope the locator to the item that gives it meaning. A page may contain several Delete buttons, but the row for a particular project should identify the one the user intends to activate:
const project = page.getByRole("listitem")
.filter({ hasText: "Website redesign" });
await expect(project).toHaveCount(1);
await expect(
project.getByRole("button", { name: "Delete", exact: true })
).toBeVisible();
A positional selector such as nth(2) asserts where an element happens to appear, not what it represents. Reordering a list can silently point the test at a different record. Prefer filtering by a stable, user-visible property, and assert uniqueness when duplicates would indicate a broken page.
When the number of matching elements is itself part of the behavior, assert the count directly. This catches duplicate notifications, repeated rows, and accidentally rendered controls:
const alerts = page.getByRole("alert");
await expect(alerts).toHaveCount(1);
await expect(alerts).toHaveText("Payment could not be processed");
For a collection, check both the number and the relevant contents rather than relying on one broad text match:
const results = page.getByRole("list", { name: "Search results" });
const resultItems = results.getByRole("listitem");
await expect(resultItems).toHaveCount(3);
await expect(resultItems).toContainText([
"Playwright documentation",
"Browser testing guide",
"API reference"
]);
Choose the narrowest assertion that expresses the contract. If all that matters is that a dialog appears, assert its role and name; if a particular control inside it matters, locate that control within the dialog. This keeps unrelated page content from satisfying the test and makes a failure point to the element the user
Wait for the page to get it right
State-of-the-art pages do not update instantly. A click may start a request, render a result, and then replace a loading indicator. A test that checks the page immediately after the click is racing the application, not testing it. Playwright’s web-first assertions retry until the condition is met or the timeout expires:
await page.getByRole("button", { name: "Save changes" }).click();
await expect(
page.getByRole("status")
).toHaveText("Your changes were saved");
Assertions such as toBeVisible, toHaveText, and toHaveValue wait for the expected state. That makes an explicit delay unnecessary in most tests. A fixed sleep only says “the test will continue after this many milliseconds”; it does not say the page is ready.
await page.waitForTimeout(2000);
That is a poor substitute for waiting on the behavior that matters. It makes fast runs slower and still fails when a request takes longer than expected. Replace the sleep with an assertion about the visible result:
await page.getByRole("button", { name: "Load profile" }).click();
await expect(
page.getByRole("heading", { name: "Account profile" })
).toBeVisible();
For navigation, wait for the destination URL or a meaningful element on the new page. A URL assertion is useful when the route itself is part of the contract:
await page.getByRole("link", { name: "Orders" }).click();
await expect(page).toHaveURL(//orders$/);
await expect(
page.getByRole("heading", { name: "Your orders" })
).toBeVisible();
For an operation that changes data, prefer waiting for the resulting UI over waiting for a particular network request. The same request can succeed while the page fails to render its response, which is precisely the kind of failure an end-to-end test should catch.
await page.getByRole("button", { name: "Add to cart" }).click();
await expect(
page.getByRole("status")
).toHaveText("Added to cart");
await expect(
page.getByRole("button", { name: "Cart, 1 item" })
).toBeVisible();
Sometimes the response itself is part of the behavior under test—for example, a download or an API-backed error state. In that case, wait for the response and still assert what the user sees afterward:
const responsePromise = page.waitForResponse(response =>
response.url().includes("/api/orders") &&
response.request().method() === "POST"
);
await page.getByRole("button", { name: "Place order" }).click();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
await expect(
page.getByRole("heading", { name: "Order confirmed" })
).toBeVisible();
When the page transitions through a loading state, assert the transition only if it’s meaningful to users. Then wait for the completed state, rather than guessing how long the loading period should last:
await page.getByRole("button", { name: "Refresh results" }).click();
await expect(page.getByRole("status")).toHaveText("Loading results");
await expect(page.getByRole("status")).toHaveText("12 results found");
Each assertion has its own retry timeout, so a timeout tells you which expected state never arrived. Keep the timeout as a limit for genuinely slow behavior, not as a substitute for identifying the element or condition the page should reach.
Source: https://www.jsfaq.com/how-to-assert-text-and-element-content-in-cypress/



