Playwright
Playwright end-to-end test rules from eslint-plugin-playwright, applied to TypeScript test files driven by the @playwright/test runner.
They guard Playwright-specific patterns that would otherwise compile and run silently: locator usage, web-first assertions, focused and slowed tests.
Source: eslint-plugin-playwright (MIT).
Rule index
Each rule name links to the detailed section below.
Examples come from the checked lint corpus or package-level rule tests when project layout matters.
Disallow
playwright/no-conditional-expect: Rejectexpect(...)calls underif/try/catchor other conditional branches in Playwright tests.playwright/no-conditional-in-test: Reject conditional logic inside Playwright test bodies.playwright/no-duplicate-hooks: Reject duplicate Playwright setup/teardown hook calls in the sametest.describe.playwright/no-duplicate-slow: Reject repeatedtest.slow()calls inside the same test.playwright/no-element-handle: Reject the legacyElementHandle-style Playwright API.playwright/no-eval: Rejectpage.$evalandpage.$$eval.playwright/no-focused-test: Rejecttest.only,test.describe.only, and similar focused Playwright tests.playwright/no-force-option: Reject Playwright{ force: true }options on actionable commands.playwright/no-get-by-title: RejectgetByTitle(...)locators.playwright/no-hooks: Reject Playwrighttest.beforeEach/test.afterEach/etc.playwright/no-nested-step: Reject nestedtest.step(...)calls.playwright/no-networkidle: Reject thenetworkidleload-state inpage.waitForLoadStateand navigation options.playwright/no-nth-methods: Reject.first(),.last(), and.nth(...)on locators.playwright/no-page-pause: Rejectpage.pause()debugging calls.playwright/no-skipped-test: Rejecttest.skip,test.describe.skip, and the conditionaltest.skip()annotation.playwright/no-slowed-test: Rejecttest.slow()marks on Playwright tests.playwright/no-standalone-expect: Rejectexpect(...)calls outside the body of a Playwright test or lifecycle hook.playwright/no-wait-for-navigation: Rejectpage.waitForNavigation.playwright/no-wait-for-selector: Rejectpage.waitForSelector.playwright/no-wait-for-timeout: Rejectpage.waitForTimeout(ms)sleeps.
Prefer
playwright/prefer-locator: Prefer locator-based Playwright APIs over page-level convenience methods.playwright/prefer-to-have-count: Preferexpect(locator).toHaveCount(n)over asserting onawait locator.count().playwright/prefer-to-have-length: Preferexpect(value).toHaveLength(n)over asserting onvalue.lengthdirectly.playwright/prefer-web-first-assertions: Prefer Playwright web-first assertions over composed manual waits.
Require
playwright/require-to-pass-timeout: Require an explicittimeoutoption onexpect(...).toPass(...).playwright/require-to-throw-message: Require a message argument onexpect(...).toThrow(...).
Validation
playwright/valid-describe-callback: Validate the shape of Playwrighttest.describecallbacks.playwright/valid-expect: Validateexpect(...)arity and matcher chaining.playwright/valid-title: Require non-empty static Playwright test anddescribetitles.
Other checks
playwright/expect-expect: Require every Playwright test body to contain at least oneexpect(...)call.playwright/max-expects: Limit the assertion count inside a single Playwright test body.
Rules
playwright/expect-expect
Require every Playwright test body to contain at least one expect(...) call. A test with no assertions still passes, giving a false-positive signal.
Example:
import { test } from "@playwright/test";
// reports: playwright/expect-expect (error)
test("loads page", async ({ page }) => {
await page.goto("/");
});playwright/max-expects
Limit the assertion count inside a single Playwright test body.
A test packed with assertions usually verifies several user flows at once, making failures ambiguous, splitting per scenario keeps each case diagnostic.
Example:
import { expect, test } from "@playwright/test";
// reports: playwright/max-expects (error)
test("has many assertions", async () => {
expect(1).toBe(1);
expect(2).toBe(2);
expect(3).toBe(3);
expect(4).toBe(4);
expect(5).toBe(5);
expect(6).toBe(6);
});playwright/no-conditional-expect
Reject expect(...) calls under if/try/catch or other conditional branches in Playwright tests.
A branch that never executes turns the assertion into a silent no-op, so the test passes without verifying anything.
Example:
import { expect, test } from "@playwright/test";
test("checks conditionally", async ({ page }) => {
if (await page.isVisible("main")) {
// reports: playwright/no-conditional-expect (error)
expect(page.url()).toContain("home");
}
});playwright/no-conditional-in-test
Reject conditional logic (if/switch/ternary) inside Playwright test bodies.
Each test should describe a single deterministic user flow; branching hides which path the runner took when reading a passing log.
Example:
import { test } from "@playwright/test";
test("branches", async ({ page }) => {
const ready = await page.isVisible("main");
// reports: playwright/no-conditional-in-test (error)
if (ready) {
await page.click("button");
}
});playwright/no-duplicate-hooks
Reject duplicate Playwright setup/teardown hook calls (test.beforeEach/test.afterEach/etc.) in the same test.describe.
Playwright runs both copies in declaration order, almost always a copy-paste mistake.
Example:
import { test } from "@playwright/test";
test.beforeEach(async () => {});
// reports: playwright/no-duplicate-hooks (error)
test.beforeEach(async () => {});playwright/no-duplicate-slow
Reject repeated test.slow() calls inside the same test, the first call already marks the test slow.
Example:
import { test } from "@playwright/test";
test("marks slow twice", async () => {
test.slow();
// reports: playwright/no-duplicate-slow (error)
test.slow();
});playwright/no-element-handle
Reject the legacy ElementHandle-style Playwright API (page.$, page.$$).
Handles snapshot the DOM at query time and bypass Playwright’s auto-waiting; locators are the modern retry-aware replacement.
Example:
import { test } from "@playwright/test";
test("gets a handle", async ({ page }) => {
// reports: playwright/no-element-handle (error)
await page.$("button");
});playwright/no-eval
Reject page.$eval and page.$$eval.
They depend on the legacy handle API and run user-supplied selectors against a frozen snapshot of the DOM, use locator.evaluate or inline logic in the test instead.
Example:
import { test } from "@playwright/test";
test("evaluates against element", async ({ page }) => {
// reports: playwright/no-eval (error)
await page.$eval("#root", (el) => el.textContent);
});playwright/no-focused-test
Reject test.only, test.describe.only, and similar focused Playwright tests.
A focused test silently skips every other test in the file, so a stray .only left from debugging hides the rest of the suite in CI.
Example:
import { test } from "@playwright/test";
// reports: playwright/no-focused-test (error)
test.only("focuses one case", async ({ page }) => {
await page.goto("/");
});playwright/no-force-option
Reject Playwright { force: true } options on actionable commands (click, fill, …).
force skips the actionability checks Playwright relies on for stability, usually papering over a real UI bug the test should be exposing.
Example:
import { test } from "@playwright/test";
test("forces click", async ({ page }) => {
// reports: playwright/no-force-option (error)
await page.getByRole("button").click({ force: true });
});playwright/no-get-by-title
Reject getByTitle(...) locators.
The title attribute is rarely set with testing in mind and often changes for i18n or cosmetic reasons, making title-based selectors among the least stable test targets.
Example:
import { test } from "@playwright/test";
test("uses title", async ({ page }) => {
// reports: playwright/no-get-by-title (error)
page.getByTitle("Settings");
});playwright/no-hooks
Reject Playwright test.beforeEach/test.afterEach/etc. hooks.
Promotes the style where each test arranges and tears down its own state inline, so a reader can understand a single case without scrolling to a distant hook.
Example:
import { test } from "@playwright/test";
// reports: playwright/no-hooks (error)
test.beforeEach(async () => {});playwright/no-nested-step
Reject nested test.step(...) calls.
Playwright’s trace viewer collapses steps into a flat timeline, so nested steps are hard to read and usually signal that the inner step should live in its own top-level entry.
Example:
import { test } from "@playwright/test";
test("steps", async () => {
await test.step("outer", async () => {
// reports: playwright/no-nested-step (error)
await test.step("inner", async () => {});
});
});playwright/no-networkidle
Reject the networkidle load-state in page.waitForLoadState and navigation options.
Modern apps stream requests on a keep-alive socket so the state is racy; the Playwright team recommends waiting on a locator instead.
Example:
import { test } from "@playwright/test";
test("waits for network", async ({ page }) => {
// reports: playwright/no-networkidle (error)
await page.waitForLoadState("networkidle");
});playwright/no-nth-methods
Reject .first(), .last(), and .nth(...) on locators.
Positional access couples the test to current DOM ordering and breaks the moment the layout changes, prefer a more specific locator (role, label, test id) that names the target directly.
Example:
import { test } from "@playwright/test";
test("uses position", async ({ page }) => {
// reports: playwright/no-nth-methods (error)
page.getByRole("button").nth(0);
});playwright/no-page-pause
Reject page.pause() debugging calls.
The helper drops the runner into Playwright’s inspector, which is useful locally but hangs CI indefinitely if it slips into a committed test.
Example:
import { test } from "@playwright/test";
test("debugs page", async ({ page }) => {
// reports: playwright/no-page-pause (error)
await page.pause();
});playwright/no-skipped-test
Reject test.skip, test.describe.skip, and the conditional test.skip() annotation.
Skipped tests appear green in CI but quietly drop coverage for the feature they were meant to pin.
Example:
import { test } from "@playwright/test";
// reports: playwright/no-skipped-test (error)
test.skip("skips one case", async ({ page }) => {
await page.goto("/");
});playwright/no-slowed-test
Reject test.slow() marks on Playwright tests.
The annotation triples the per-test timeout, usually masking a real performance regression instead of fixing the underlying wait.
Example:
import { test } from "@playwright/test";
// reports: playwright/no-slowed-test (error)
test.slow();playwright/no-standalone-expect
Reject expect(...) calls outside the body of a Playwright test or lifecycle hook.
Top-level assertions execute at module load before any test starts, so failures never attach to a named case in the runner’s report.
Example:
import { expect } from "@playwright/test";
// reports: playwright/no-standalone-expect (error)
expect(1).toBe(1);playwright/no-wait-for-navigation
Reject page.waitForNavigation.
The helper races against navigations triggered by the very command preceding it; use a locator-based wait or the action’s own waitUntil option to pin the post-navigation state explicitly.
Example:
import { test } from "@playwright/test";
test("waits for navigation", async ({ page }) => {
// reports: playwright/no-wait-for-navigation (error)
await page.waitForNavigation();
});playwright/no-wait-for-selector
Reject page.waitForSelector.
The helper duplicates locator auto-waiting but does not share its retry semantics, prefer locator.waitFor() or a web-first assertion on the same locator.
Example:
import { test } from "@playwright/test";
test("waits for selector", async ({ page }) => {
// reports: playwright/no-wait-for-selector (error)
await page.waitForSelector("button");
});playwright/no-wait-for-timeout
Reject page.waitForTimeout(ms) sleeps.
A hardcoded delay is either too short under load (flaky failures) or too long under normal conditions (slow suite); wait on a locator or response that signals readiness directly.
Example:
import { test } from "@playwright/test";
test("waits explicitly", async ({ page }) => {
// reports: playwright/no-wait-for-timeout (error)
await page.waitForTimeout(1000);
});playwright/prefer-locator
Prefer locator-based Playwright APIs (page.locator(sel).click()) over page-level convenience methods (page.click(selector)).
Page methods re-resolve the selector each call and skip the retry semantics that make locators robust under animation.
Example:
import { test } from "@playwright/test";
test("clicks selector", async ({ page }) => {
// reports: playwright/prefer-locator (error)
await page.click("button");
});playwright/prefer-to-have-count
Prefer expect(locator).toHaveCount(n) over asserting on await locator.count().
The web-first matcher retries until the count stabilizes, while the awaited value is a one-shot snapshot that races against ongoing renders.
Example:
import { expect, test } from "@playwright/test";
test("counts", async ({ page }) => {
// reports: playwright/prefer-to-have-count (error)
expect(await page.locator("li").count()).toBe(2);
});playwright/prefer-to-have-length
Prefer expect(value).toHaveLength(n) over asserting on value.length directly.
The dedicated matcher reports the actual length on failure instead of a bare number mismatch.
Example:
import { expect, test } from "@playwright/test";
test("checks length", async ({ page }) => {
const collection = page.locator("li");
// reports: playwright/prefer-to-have-length (error)
expect(await collection.length()).toBe(2);
});playwright/prefer-web-first-assertions
Prefer Playwright web-first assertions (expect(locator).toBeVisible(), .toHaveText(...)) over composed manual waits.
Web-first matchers retry until the predicate holds, which is how Playwright keeps tests stable under animation and network jitter.
Example:
import { expect, test } from "@playwright/test";
test("checks visibility", async ({ page }) => {
const submit = page.getByRole("button");
// reports: playwright/prefer-web-first-assertions (error)
expect(await submit.isVisible()).toBe(true);
});playwright/require-to-pass-timeout
Require an explicit timeout option on expect(...).toPass(...).
The matcher otherwise inherits the suite-wide timeout and can strand CI when the underlying assertion never settles.
Example:
import { expect, test } from "@playwright/test";
test("passes eventually", async () => {
// reports: playwright/require-to-pass-timeout (error)
await expect(async () => {}).toPass();
});playwright/require-to-throw-message
Require a message argument on expect(...).toThrow(...).
Without a message the assertion only confirms that something threw, so a regression that throws a different error still looks green.
Example:
import { expect, test } from "@playwright/test";
test("throws", async () => {
// reports: playwright/require-to-throw-message (error)
expect(() => {
throw new Error("boom");
}).toThrow();
});playwright/valid-describe-callback
Validate the shape of Playwright test.describe callbacks.
The callback must be synchronous and take no arguments, Playwright ignores returned Promises and stray parameters at the describe level, silently swallowing setup errors.
Example:
import { test } from "@playwright/test";
// reports: playwright/valid-describe-callback (error)
test.describe("suite", async () => {});playwright/valid-expect
Validate expect(...) arity and matcher chaining: exactly one argument, terminated by a matcher call, and async matchers properly awaited.
Malformed expects either throw at runtime or pass without asserting anything.
Example:
import { expect, test } from "@playwright/test";
test("expects", async () => {
// reports: playwright/valid-expect (error)
expect();
});playwright/valid-title
Require non-empty static Playwright test and describe titles.
Empty or dynamically-built titles produce unreadable reports and break filter-by-name flags like --grep.
Example:
import { test } from "@playwright/test";
// reports: playwright/valid-title (error)
test("", async () => {});