"Playwright doesn't have chained assertions", mentioned by the other answer, is technically true, but is missing a critical point: all Playwright locator actions are implicit assertions, and will lead to clear error messages when they fail. And since locators are chainable, Playwright does have chained assertions.
When you do
await page.getByRole("h1", {text: "test"}).click();
if no <h1>text</h1> element is found within the allotted timeout, the test will fail as expected with a descriptive log, just as if an expect() assertion had failed.
Playwright will also throw if the element was found in the document, but the .click() action on that element could not be completed, likely because the element wasn't actionable (in a disabled state, not visible, etc). .click() is an implicit assertion.
If you look at a typical Playwright test case, even if there are only a few explicit expect() assertions, there are probably 8-10 implicit assertions that need to be true before control flow even reaches the explicit expect()s at the end of the test.
With that in mind, how this.heading was defined matters. If it's defined as
this.heading = page.getByRole("h1", {name: "test"});
// ...
await expect(this.heading).toBeVisible();
using the best practice .getByRole(), which is an accessibility-first, user-visible assertion, then all of OP's conditions are asserted in one line. This reduces to the one-liner:
await expect(page.getByRole("h1", {name: "test"})).toBeVisible();
Which validates a few things:
- The element is an
<h1> element.
- The element has text content containing
"test" (you can pass exact: true if you don't want a substring match).
- The element is visible (does not have other elements on top of it and has a non-zero bounding box).
On the other hand, if this.heading was defined more broadly, for example, page.locator("h1"), then .locator() or .filter() would need to be used to assert its text as well as visibility. This can be done in a chained manner (but I don't recommend doing this; better to use getByRole):
import {expect, test} from "@playwright/test"; // ^1.46.1
test("Header has text 'test'", async ({page}) => {
await page.setContent("<h1>test</h1>");
const heading = page.locator("h1"); // kinda vague, not recommended
// Use chaining on the *locator*, not on the assertion!
await expect(heading.locator("text=test")).toBeVisible();
});
Of course, you can make this as complex as you want:
test("Header has text 'test' and some other stuff for demonstration", async ({
page,
}) => {
await page.setContent('<h1 class="foo">test <span>bar</span></h1>');
const heading = page.locator("h1"); // kinda vague, not recommended
// Use chaining on the *locator*, not on the assertion!
await expect(
heading
.locator("text=test")
.filter({has: page.locator("span").locator("text=bar")})
.locator(".foo:scope")
.or(page.locator("etc etc"))
).toBeVisible();
});
This shows that Playwright does have chained implicit assertions, only in a different place than expected.
Now, I don't really recommend this pattern. If the assertion fails, it'll be unclear which predicate failed:
Running 1 test using 1 worker
✘ 1 …1 › Header has text 'test' and some other stuff for demonstration (5.1s)
1) pw1.test.js:3:1 › Header has text 'test' and some other stuff for demonstration
Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
Locator: locator('h1').locator('text=test').filter({ has: locator('span').locator('text=bar') }).locator('.foo').or(locator('etc etc'))
Expected: visible
Received: <element(s) not found>
Call log:
- expect.toBeVisible with timeout 5000ms
- waiting for locator('h1').locator('text=test').filter({ has: locator('span').locator('text=bar') }).locator('.foo').or(locator('etc etc'))
14 | .locator(".foo")
15 | .or(page.locator("etc etc"))
> 16 | ).toBeVisible();
| ^
17 | });
18 |
So although this proves the concept, it's still better to break operations onto different lines and keep locators tight, user-visible and simple, without too much chaining.
As somewhat of a consequence of all this, a common antipattern in Playwright tests are redundant assertions. If you can click an element, you don't need to assert that it's visible, since visibility checks are already part of .click().
With this in mind, you'll find that many chained assertions aren't necessary in the first place--Playwright is doing a lot of asserting at every step to make sure the app behavior is as you intend it to be.
Taking another step back, it's OK to use less chaining nowadays. Playwright uses it in defining locators, but in modern async JS programming, using await on every line, using plenty of intermediate variables, and not chaining things is considered perfectly readable and elegant. Playwright intentionally uses a more imperative style.
Cypress is an older library, written when JS was (arguably somewhat unhealthily) obsessed with fluent programming and heavy chaining. The ecosystem has moved away from that somewhat, taking a more mature, balanced approach to different programming styles and paradigms.
this.headingdefined? If you select by text, then you can chain two assertions in one line, essentially, something likeawait expect(page.getByRole("h1", {name: "test"}).toBeVisible(). Also, if you're ultimately taking an action on it, like clicking, running a.click()action implicitly checks visibility. So the question needs more info/context to be fully answerable. There's also the.and()locator method which lets you chain locators, which are implicit assertions.