6 Custom ESLint Rules That Forced My Playwright Tests to Grow Up

Playwright is powerful.

That’s also the problem.

It lets teams move fast, and when teams move fast, test quality quietly degrades. You end up with unreadable assertions, inconsistent page objects, slow test runs, and Allure reports nobody trusts.

I stopped trying to “educate” people in code reviews. Instead, I wrote ESLint rules.

These six custom rules now guard our Playwright codebase. They are strict, sometimes annoying, and absolutely worth it.


1. Require descriptive assertion messages

The problem

This failure tells you nothing:

Expected true to be false

At scale, missing assertion messages are a tax on every failure.

What the rule enforces

  • Every expect() must include a meaningful message
  • Applies to expect.poll() and expect.toPass()
  • Empty or whitespace-only messages are forbidden

Bad

await expect(isVisible).toBe(true)

Good

await expect(
  isVisible,
  'Submit button should be visible after successful validation'
).toBe(true)

Why it matters

Assertion messages are part of your debugging experience.

If a test fails without context, the test is incomplete.


2. Wrap page actions in test.step

The problem

Allure reports that look like this are useless:

Test passed

Or worse, hundreds of meaningless nested steps.

What the rule enforces

  • Page-action methods must be wrapped in test.step
  • No nested test.step calls
  • Utility or getter methods are ignored
  • this.commonActions.* is allowed without wrapping

Bad

async submitForm() {
  await this.submitButton.click()
}

Good

async submitForm() {
  await test.step('Submit the form', async () => {
    await this.submitButton.click()
  })
}

Why it matters

Reports should tell a story.

If an action is meaningful enough to be a method, it’s meaningful enough to be a step.


3. Enforce a real base page object

The problem

Everyone creates their own “page object style”.

Soon you have:

  • Different constructor patterns
  • Direct page access everywhere
  • Hidden lifecycle bugs

What the rule enforces

  • All page objects must extend BasePageObject
  • No page fields
  • No this.page = page
  • Constructor must call super(page)

Bad

class LoginPage {
  constructor(page) {
    this.page = page
  }
}

Good

class LoginPage extends BasePageObject {
  constructor(page) {
    super(page)
  }
}

Why it matters

Consistency beats flexibility.

Base classes exist to remove decisions, not create them.


4. Always use this.page.locator() in constructors

The problem

Using bare page.locator() works… until it doesn’t.

It makes refactoring harder and breaks abstraction.

What the rule enforces

  • In constructors, locators must be created via this.page.locator()
  • Direct page.locator() usage is forbidden

Bad

this.submitButton = page.locator('#submit')

Good

this.submitButton = this.page.locator('#submit')

Why it matters

Future-proofing.

If your base page ever changes how page is managed, your locators won’t explode.


5. Require a blank line after test()

The problem

Dense tests are harder to scan than failing tests.

What the rule enforces

  • A blank line after each test() declaration

Bad

test('login works', async () => {
  await login()
})
const user = await getUser()

Good

test('login works', async () => {
  await login()
})

const user = await getUser()

Why it matters

This sounds trivial. It isn’t.

Whitespace is structure.

Readable tests are maintained. Unreadable ones get skipped.


6. Run multiple assertions in parallel with Promise.all

The problem

Sequential assertions slow tests for no reason.

This is common and unnecessary:

await expect(a).toBeVisible()
await expect(b).toBeVisible()
await expect(c).toBeVisible()

What the rule enforces

  • Multiple expect() calls must run in Promise.all
  • Applies to arrays or consecutive awaits

Good

await Promise.all([
  expect(a, 'A should be visible').toBeVisible(),
  expect(b, 'B should be visible').toBeVisible(),
  expect(c, 'C should be visible').toBeVisible(),
])

Why it matters

  • Faster tests
  • Same reliability
  • Clear intent

There’s no upside to doing this sequentially.


What changed after enforcing these rules

  • Test failures became actionable
  • Allure reports became readable
  • Page objects stopped drifting
  • Test runtime dropped
  • Code reviews got shorter and quieter

Yes, some people complained at first.

Then they stopped writing bad tests.


Who this is for

  • Teams with growing Playwright suites
  • Projects with multiple contributors
  • Anyone tired of “guidelines” nobody follows

If your tests are already perfect, you don’t need this.

If they aren’t, linting is cheaper than discipline.


Final take

Playwright gives you freedom.

ESLint gives you standards.

If you care about test quality at scale, rules beat conventions every time.

If you want these rules open-sourced, documented, or packaged cleanly, do it.

Someone else is fighting the same problems.