Repository: playwright-community/eslint-plugin-playwright (ts)

Issue:
Prefer using native `getBy*` selectors
I've been going through our fairly large suite of Playwright tests and I noticed a common pattern that I think would be great to correct. If I have some time, I will try to contribute a rule for this.

The issue is that developers write a test like `expect(page.locator('[aria-label="View more"]'))` which works great until the element is updated to use `aria-labelledby` for labeling, instead of explicit `aria-label` attributes. From a semantic perspective this is equivalent, but it breaks the Playwright test. We should encourage people to use `getByLabel` instead in the first place to prevent this from breaking. It is easier to read, easier to maintain, and encourages an accessibility-first focus.

Examples:

```ts
// Incorrect:
page.locator('[aria-label="View more"]')
// Correct:
page.getByLabel('View more')
```

```ts
// Incorrect:
page.locator('[role="button"]')
// Correct:
page.getByRole('button')
```

```ts
// Incorrect:
page.locator('[placeholder="Enter some text..."]')
// Correct:
page.getByPlaceholder('Enter some text...')
```

This could also be an extension to `no-raw-locators`, but more like a specific auto-fixer that should be safe to use. For example, we don't use `no-raw-locators` in our codebase because we query plenty of attributes that are fine to use, but a rule like this would be very useful for us.

Pull request description:
Add rule to suggest using built-in locators (`prefer-native-locators`)
- Closes #203 

This PR adds a rule for suggesting usage of built-in locators when there is an equivalent to a custom locator that is being used. Rather than trying to rewrite all possible custom locators, I've focused on a cases that are auto-fixable and should have a low false positive rate. So, more complex locators (including text queries) were not included in this initial PR. We should expand this rule to accommodate for other cases in the future.

Patch:
diff --git a/README.md b/README.md
index b4c0e02..73d387a 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,7 @@ CLI option\
 | [prefer-hooks-in-order](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-in-order.md)             | Prefer having hooks in a consistent order                          |     |     |     |
 | [prefer-hooks-on-top](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-on-top.md)                 | Suggest having hooks before any test cases                         |     |     |     |
 | [prefer-lowercase-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md)           | Enforce lowercase test names                                       |     | 🔧  |     |
+| [prefer-native-locators](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-native-locators.md)           | Suggest built-in locators over `page.locator()`                    |     | 🔧  |     |
 | [prefer-strict-equal](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md)                 | Suggest using `toStrictEqual()`                                    |     |     | 💡  |
 | [prefer-to-be](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md)                               | Suggest using `toBe()`                                             |     | 🔧  |     |
 | [prefer-to-contain](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md)                     | Suggest using `toContain()`                                        |     | 🔧  |     |
diff --git a/docs/rules/prefer-native-locators.md b/docs/rules/prefer-native-locators.md
new file mode 100644
index 0000000..22aadfa
--- /dev/null
+++ b/docs/rules/prefer-native-locators.md
@@ -0,0 +1,70 @@
+# Suggest using native Playwright locators (`prefer-native-locators`)
+
+Playwright has built-in locators for common query selectors such as finding
+elements by placeholder text, ARIA role, accessible name, and more. This rule
+suggests using these native locators instead of using `page.locator()` with an
+equivalent selector.
+
+In some cases this can be more robust too, such as finding elements by ARIA role
+or accessible name, because some elements have implicit roles, and there are
+multiple ways to specify accessible names.
+
+## Rule details
+
+Examples of **incorrect** code for this rule:
+
+```javascript
+page.locator('[aria-label="View more"]')
+page.locator('[role="button"]')
+page.locator('[placeholder="Enter some text..."]')
+page.locator('[alt="Playwright logo"]')
+page.locator('[title="Additional context"]')
+page.locator('[data-testid="password-input"]')
+```
+
+Examples of **correct** code for this rule:
+
+```javascript
+page.getByLabel('View more')
+page.getByRole('Button')
+page.getByPlaceholder('Enter some text...')
+page.getByAltText('Playwright logo')
+page.getByTestId('password-input')
+page.getByTitle('Additional context')
+```
+
+## Options
+
+```json
+{
+  "playwright/prefer-native-locators": [
+    "error",
+    {
+      "testIdAttribute": "data-testid"
+    }
+  ]
+}
+```
+
+### `testIdAttribute`
+
+Default: `data-testid`
+
+This string option specifies the test ID attribute to look for and replace with
+`page.getByTestId()` calls. If you are using
+[`page.setTestIdAttribute()`](https://playwright.dev/docs/api/class-selectors#selectors-set-test-id-attribute),
+this should be set to the same value as what you pass in to that method.
+
+Examples of **incorrect** code when using
+`{ "testIdAttribute": "data-custom-testid" }` option:
+
+```js
+page.locator('[data-custom-testid="password-input"]')
+```
+
+Examples of **correct** code when using
+`{ "testIdAttribute": "data-custom-testid" }` option:
+
+```js
+page.getByTestId('password-input')
+```
diff --git a/docs/rules/valid-title.md b/docs/rules/valid-title.md
index 5e69637..e8393e6 100644
--- a/docs/rules/valid-title.md
+++ b/docs/rules/valid-title.md
@@ -143,6 +143,7 @@ test.describe('foo', () => {
 ```ts
 interface Options {
   ignoreSpaces?: boolean
+  ignoreTypeOfStepName?: boolean
   ignoreTypeOfTestName?: boolean
   ignoreTypeOfDescribeName?: boolean
   disallowedWords?: string[]
@@ -157,6 +158,13 @@ Default: `false`
 
 When enabled, the leading and trailing spaces won't be checked.
 
+#### `ignoreTypeOfStepName`
+
+Default: `true`
+
+When enabled, the type of the first argument to `test.step` blocks won't be
+checked.
+
 #### `ignoreTypeOfDescribeName`
 
 Default: `false`
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5268f66..e6fb510 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1080,7 +1080,7 @@ packages:
       debug: 4.3.4
       import-from-esm: 1.3.3
       lodash-es: 4.17.21
-      micromatch: 4.0.5
+      micromatch: 4.0.8
       semantic-release: 23.0.2(typescript@5.2.2)
     transitivePeerDependencies:
       - supports-color
@@ -2391,7 +2391,7 @@ packages:
       '@nodelib/fs.walk': 1.2.8
       glob-parent: 5.1.2
       merge2: 1.4.1
-      micromatch: 4.0.5
+      micromatch: 4.0.8
 
   /fast-glob@3.3.2:
     resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
@@ -2401,7 +2401,7 @@ packages:
       '@nodelib/fs.walk': 1.2.8
       glob-parent: 5.1.2
       merge2: 1.4.1
-      micromatch: 4.0.5
+      micromatch: 4.0.8
     dev: true
 
   /fast-json-stable-stringify@2.1.0:
@@ -3284,8 +3284,8 @@ packages:
       - supports-color
     dev: true
 
-  /micromatch@4.0.5:
-    resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
+  /micromatch@4.0.8:
+    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
     engines: {node: '>=8.6'}
     dependencies:
       braces: 3.0.3
@@ -3995,7 +3995,7 @@ packages:
       lodash-es: 4.17.21
       marked: 12.0.0
       marked-terminal: 7.0.0(marked@12.0.0)
-      micromatch: 4.0.5
+      micromatch: 4.0.8
       p-each-series: 3.0.0
       p-reduce: 3.0.0
       read-pkg-up: 11.0.0
diff --git a/src/index.ts b/src/index.ts
index e37a74c..c185547 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -31,6 +31,7 @@ import preferEqualityMatcher from './rules/prefer-equality-matcher'
 import preferHooksInOrder from './rules/prefer-hooks-in-order'
 import preferHooksOnTop from './rules/prefer-hooks-on-top'
 import preferLowercaseTitle from './rules/prefer-lowercase-title'
+import preferNativeLocators from './rules/prefer-native-locators'
 import preferStrictEqual from './rules/prefer-strict-equal'
 import preferToBe from './rules/prefer-to-be'
 import preferToContain from './rules/prefer-to-contain'
@@ -81,6 +82,7 @@ const index = {
     'prefer-hooks-in-order': preferHooksInOrder,
     'prefer-hooks-on-top': preferHooksOnTop,
     'prefer-lowercase-title': preferLowercaseTitle,
+    'prefer-native-locators': preferNativeLocators,
     'prefer-strict-equal': preferStrictEqual,
     'prefer-to-be': preferToBe,
     'prefer-to-contain': preferToContain,
diff --git a/src/rules/prefer-native-locators.ts b/src/rules/prefer-native-locators.ts
new file mode 100644
index 0000000..25a38e5
--- /dev/null
+++ b/src/rules/prefer-native-locators.ts
@@ -0,0 +1,123 @@
+import { AST } from 'eslint'
+import { getStringValue, isPageMethod } from '../utils/ast'
+import { createRule } from '../utils/createRule'
+
+type Pattern = {
+  messageId: string
+  pattern: RegExp
+  replacement: string
+}
+
+const compilePatterns = ({
+  testIdAttribute,
+}: {
+  testIdAttribute: string
+}): Pattern[] => {
+  const patterns = [
+    {
+      attribute: 'aria-label',
+      messageId: 'unexpectedLabelQuery',
+      replacement: 'getByLabel',
+    },
+    {
+      attribute: 'role',
+      messageId: 'unexpectedRoleQuery',
+      replacement: 'getByRole',
+    },
+    {
+      attribute: 'placeholder',
+      messageId: 'unexpectedPlaceholderQuery',
+      replacement: 'getByPlaceholder',
+    },
+    {
+      attribute: 'alt',
+      messageId: 'unexpectedAltTextQuery',
+      replacement: 'getByAltText',
+    },
+    {
+      attribute: 'title',
+      messageId: 'unexpectedTitleQuery',
+      replacement: 'getByTitle',
+    },
+    {
+      attribute: testIdAttribute,
+      messageId: 'unexpectedTestIdQuery',
+      replacement: 'getByTestId',
+    },
+  ]
+  return patterns.map(({ attribute, ...pattern }) => ({
+    ...pattern,
+    pattern: new RegExp(`^\\[${attribute}=['"]?(.+?)['"]?\\]$`),
+  }))
+}
+
+export default createRule({
+  create(context) {
+    const { testIdAttribute } = {
+      testIdAttribute: 'data-testid',
+      ...((context.options?.[0] as Record<string, unknown>) ?? {}),
+    }
+
+    const patterns = compilePatterns({ testIdAttribute })
+
+    return {
+      CallExpression(node) {
+        if (node.callee.type !== 'MemberExpression') return
+        const query = getStringValue(node.arguments[0])
+        if (!isPageMethod(node, 'locator')) return
+
+        for (const pattern of patterns) {
+          const match = query.match(pattern.pattern)
+          if (match) {
+            context.report({
+              fix(fixer) {
+                const start =
+                  node.callee.type === 'MemberExpression'
+                    ? node.callee.property.range![0]
+                    : node.range![0]
+                const end = node.range![1]
+                const rangeToReplace: AST.Range = [start, end]
+
+                const newText = `${pattern.replacement}("${match[1]}")`
+                return fixer.replaceTextRange(rangeToReplace, newText)
+              },
+              messageId: pattern.messageId,
+              node,
+            })
+            return
+          }
+        }
+      },
+    }
+  },
+  meta: {
+    docs: {
+      category: 'Best Practices',
+      description: 'Prefer native locator functions',
+      recommended: false,
+      url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-native-locators.md',
+    },
+    fixable: 'code',
+    messages: {
+      unexpectedAltTextQuery: 'Use getByAltText() instead',
+      unexpectedLabelQuery: 'Use getByLabel() instead',
+      unexpectedPlaceholderQuery: 'Use getByPlaceholder() instead',
+      unexpectedRoleQuery: 'Use getByRole() instead',
+      unexpectedTestIdQuery: 'Use getByTestId() instead',
+      unexpectedTitleQuery: 'Use getByTitle() instead',
+    },
+    schema: [
+      {
+        additionalProperties: false,
+        properties: {
+          testIdAttribute: {
+            default: 'data-testid',
+            type: 'string',
+          },
+        },
+        type: 'object',
+      },
+    ],
+    type: 'suggestion',
+  },
+})
diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts
index 004b1e7..30d9f6d 100644
--- a/src/rules/valid-title.ts
+++ b/src/rules/valid-title.ts
@@ -44,6 +44,7 @@ const compileMatcherPatterns = (
 
     return {
       describe: compiledMatcher,
+      step: compiledMatcher,
       test: compiledMatcher,
     }
   }
@@ -52,6 +53,7 @@ const compileMatcherPatterns = (
     describe: matchers.describe
       ? compileMatcherPattern(matchers.describe)
       : null,
+    step: matchers.step ? compileMatcherPattern(matchers.step) : null,
     test: matchers.test ? compileMatcherPattern(matchers.test) : null,
   }
 }
@@ -67,12 +69,13 @@ const MatcherAndMessageSchema = {
   type: 'array',
 } as const
 
-type MatcherGroups = 'describe' | 'test'
+type MatcherGroups = 'describe' | 'step' | 'test'
 
 interface Options {
   disallowedWords?: string[]
   ignoreSpaces?: boolean
   ignoreTypeOfDescribeName?: boolean
+  ignoreTypeOfStepName?: boolean
   ignoreTypeOfTestName?: boolean
   mustMatch?:
     | Partial<Record<MatcherGroups, string | MatcherAndMessage>>
@@ -91,6 +94,7 @@ export default createRule({
       disallowedWords = [],
       ignoreSpaces = false,
       ignoreTypeOfDescribeName = false,
+      ignoreTypeOfStepName = true,
       ignoreTypeOfTestName = false,
       mustMatch,
       mustNotMatch,
@@ -106,7 +110,11 @@ export default createRule({
     return {
       CallExpression(node) {
         const call = parseFnCall(context, node)
-        if (call?.type !== 'test' && call?.type !== 'describe') {
+        if (
+          call?.type !== 'test' &&
+          call?.type !== 'describe' &&
+          call?.type !== 'step'
+        ) {
           return
         }
 
@@ -124,7 +132,8 @@ export default createRule({
           if (
             !(
               (call.type === 'describe' && ignoreTypeOfDescribeName) ||
-              (call.type === 'test' && ignoreTypeOfTestName)
+              (call.type === 'test' && ignoreTypeOfTestName) ||
+              (call.type === 'step' && ignoreTypeOfStepName)
             ) &&
             (argument as ESTree.Node).type !== 'TemplateLiteral'
           ) {
@@ -262,7 +271,7 @@ export default createRule({
                 additionalProperties: {
                   oneOf: [{ type: 'string' }, MatcherAndMessageSchema],
                 },
-                propertyNames: { enum: ['describe', 'test'] },
+                propertyNames: { enum: ['describe', 'test', 'step'] },
                 type: 'object',
               },
             ],
@@ -281,6 +290,10 @@ export default createRule({
             default: false,
             type: 'boolean',
           },
+          ignoreTypeOfStepName: {
+            default: true,
+            type: 'boolean',
+          },
           ignoreTypeOfTestName: {
             default: false,
             type: 'boolean',


Test patch:
diff --git a/src/rules/prefer-native-locators.test.ts b/src/rules/prefer-native-locators.test.ts
new file mode 100644
index 0000000..d795fd3
--- /dev/null
+++ b/src/rules/prefer-native-locators.test.ts
@@ -0,0 +1,194 @@
+import { runRuleTester } from '../utils/rule-tester'
+import rule from './prefer-native-locators'
+
+runRuleTester('prefer-native-locators', rule, {
+  invalid: [
+    {
+      code: `page.locator('[aria-label="View more"]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedLabelQuery' }],
+      output: 'page.getByLabel("View more")',
+    },
+    {
+      code: `page.locator('[aria-label=Edit]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedLabelQuery' }],
+      output: 'page.getByLabel("Edit")',
+    },
+    {
+      code: `page.locator('[role="button"]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedRoleQuery' }],
+      output: 'page.getByRole("button")',
+    },
+    {
+      code: `page.locator('[role=button]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedRoleQuery' }],
+      output: 'page.getByRole("button")',
+    },
+    {
+      code: `page.locator('[placeholder="Enter some text..."]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedPlaceholderQuery' }],
+      output: 'page.getByPlaceholder("Enter some text...")',
+    },
+    {
+      code: `page.locator('[placeholder=Name]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedPlaceholderQuery' }],
+      output: 'page.getByPlaceholder("Name")',
+    },
+    {
+      code: `page.locator('[alt="Playwright logo"]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedAltTextQuery' }],
+      output: 'page.getByAltText("Playwright logo")',
+    },
+    {
+      code: `page.locator('[alt=Logo]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedAltTextQuery' }],
+      output: 'page.getByAltText("Logo")',
+    },
+    {
+      code: `page.locator('[title="Additional context"]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedTitleQuery' }],
+      output: 'page.getByTitle("Additional context")',
+    },
+    {
+      code: `page.locator('[title=Context]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedTitleQuery' }],
+      output: 'page.getByTitle("Context")',
+    },
+    {
+      code: `page.locator('[data-testid="password-input"]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
+      output: 'page.getByTestId("password-input")',
+    },
+    {
+      code: `page.locator('[data-testid=input]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
+      output: 'page.getByTestId("input")',
+    },
+    {
+      code: `page.locator('[data-custom-testid="password-input"]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
+      options: [{ testIdAttribute: 'data-custom-testid' }],
+      output: 'page.getByTestId("password-input")',
+    },
+    {
+      code: `page.locator('[data-custom-testid=input]')`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
+      options: [{ testIdAttribute: 'data-custom-testid' }],
+      output: 'page.getByTestId("input")',
+    },
+    // Works when locators are chained
+    {
+      code: `this.page.locator('[role="heading"]').first()`,
+      errors: [{ column: 1, line: 1, messageId: 'unexpectedRoleQuery' }],
+      output: 'this.page.getByRole("heading").first()',
+    },
+    // Works when used inside an assertion
+    {
+      code: `await expect(page.locator('[role="alert"]')).toBeVisible()`,
+      errors: [{ column: 14, line: 1, messageId: 'unexpectedRoleQuery' }],
+      output: 'await expect(page.getByRole("alert")).toBeVisible()',
+    },
+    {
+      code: `await expect(page.locator('[data-testid="top"]')).toContainText(firstRule)`,
+      errors: [{ column: 14, line: 1, messageId: 'unexpectedTestIdQuery' }],
+      output: 'await expect(page.getByTestId("top")).toContainText(firstRule)',
+    },
+    // Works when used as part of an action
+    {
+      code: `await page.locator('[placeholder="New password"]').click()`,
+      errors: [{ column: 7, line: 1, messageId: 'unexpectedPlaceholderQuery' }],
+      output: 'await page.getByPlaceholder("New password").click()',
+    },
+    // Works as part of a declaration or other usage
+    {
+      code: `const dialog = page.locator('[role="dialog"]')`,
+      errors: [{ column: 16, line: 1, messageId: 'unexpectedRoleQuery' }],
+      output: 'const dialog = page.getByRole("dialog")',
+    },
+    {
+      code: `this.closeModalLocator = this.page.locator('[data-test=close-modal]');`,
+      errors: [{ column: 26, line: 1, messageId: 'unexpectedTestIdQuery' }],
+      options: [{ testIdAttribute: 'data-test' }],
+      output: 'this.closeModalLocator = this.page.getByTestId("close-modal");',
+    },
+    {
+      code: `export class TestClass {
+        container = () => this.page.locator('[data-testid="container"]');
+      }`,
+      errors: [{ column: 27, line: 2, messageId: 'unexpectedTestIdQuery' }],
+      output: `export class TestClass {
+        container = () => this.page.getByTestId("container");
+      }`,
+    },
+    {
+      code: `export class TestClass {
+        get alert() {
+          return this.page.locator("[role='alert']");
+        }
+      }`,
+      errors: [{ column: 18, line: 3, messageId: 'unexpectedRoleQuery' }],
+      output: `export class TestClass {
+        get alert() {
+          return this.page.getByRole("alert");
+        }
+      }`,
+    },
+  ],
+  valid: [
+    { code: 'page.getByLabel("View more")' },
+    { code: 'page.getByRole("button")' },
+    { code: 'page.getByRole("button", {name: "Open"})' },
+    { code: 'page.getByPlaceholder("Enter some text...")' },
+    { code: 'page.getByAltText("Playwright logo")' },
+    { code: 'page.getByTestId("password-input")' },
+    { code: 'page.getByTitle("Additional context")' },
+    { code: 'this.page.getByLabel("View more")' },
+    { code: 'this.page.getByRole("button")' },
+    { code: 'this.page.getByPlaceholder("Enter some text...")' },
+    { code: 'this.page.getByAltText("Playwright logo")' },
+    { code: 'this.page.getByTestId("password-input")' },
+    { code: 'this.page.getByTitle("Additional context")' },
+    { code: 'page.locator(".class")' },
+    { code: 'page.locator("#id")' },
+    { code: 'this.page.locator("#id")' },
+    // Does not match on more complex queries
+    {
+      code: `page.locator('[complex-query] > [aria-label="View more"]')`,
+    },
+    {
+      code: `page.locator('[complex-query] > [role="button"]')`,
+    },
+    {
+      code: `page.locator('[complex-query] > [placeholder="Enter some text..."]')`,
+    },
+    {
+      code: `page.locator('[complex-query] > [alt="Playwright logo"]')`,
+    },
+    {
+      code: `page.locator('[complex-query] > [data-testid="password-input"]')`,
+    },
+    {
+      code: `page.locator('[complex-query] > [title="Additional context"]')`,
+    },
+    {
+      code: `this.page.locator('[complex-query] > [title="Additional context"]')`,
+    },
+    // Works for empty string and no arguments
+    { code: `page.locator('')` },
+    { code: `page.locator()` },
+    // Works for classes and declarations
+    { code: `const dialog = page.getByRole("dialog")` },
+    {
+      code: `export class TestClass {
+        get alert() {
+          return this.page.getByRole("alert");
+        }
+      }`,
+    },
+    {
+      code: `export class TestClass {
+        container = () => this.page.getByTestId("container");
+      }`,
+    },
+    { code: `this.closeModalLocator = this.page.getByTestId("close-modal");` },
+  ],
+})
diff --git a/src/rules/valid-title.test.ts b/src/rules/valid-title.test.ts
index 9a11ffb..8263a51 100644
--- a/src/rules/valid-title.test.ts
+++ b/src/rules/valid-title.test.ts
@@ -63,6 +63,18 @@ runRuleTester('valid-title', rule, {
       ],
       options: [{ disallowedWords: ['properly'] }],
     },
+    {
+      code: 'test.step(`that the value is set properly`, function () {})',
+      errors: [
+        {
+          column: 11,
+          data: { word: 'properly' },
+          line: 1,
+          messageId: 'disallowedWord',
+        },
+      ],
+      options: [{ disallowedWords: ['properly'] }],
+    },
     // Global aliases
     {
       code: 'it("the correct way to properly handle all things", () => {});',
@@ -142,7 +154,9 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           });
 
           test.describe('e2e tests #e4e', () => {
-            test('is another test #e2e #playwright4life', () => {});
+            test('is another test #e2e #playwright4life', () => {
+              test.step('#wow', () => {});
+            });
           });
         });
       `,
@@ -165,6 +179,15 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           line: 9,
           messageId: 'mustNotMatch',
         },
+        {
+          column: 17,
+          data: {
+            functionName: 'step',
+            pattern: /(?:#(?!unit|e2e))\w+/u,
+          },
+          line: 10,
+          messageId: 'mustNotMatch',
+        },
       ],
       options: [
         {
@@ -183,7 +206,9 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           });
 
           test.describe('e2e tests #e4e', () => {
-            test('is another test #e2e #playwright4life', () => {});
+            test('is another test #e2e #playwright4life', () => {
+              test.step('#wow', () => {});
+            });
           });
         });
       `,
@@ -208,6 +233,15 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           line: 9,
           messageId: 'mustNotMatchCustom',
         },
+        {
+          column: 17,
+          data: {
+            message: 'Please include "#unit" or "#e2e" in titles',
+            pattern: /(?:#(?!unit|e2e))\w+/u,
+          },
+          line: 10,
+          messageId: 'mustNotMatchCustom',
+        },
       ],
       options: [
         {
@@ -232,7 +266,9 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           });
 
           test.describe('e2e tests #e4e', () => {
-            test('is another test #e2e #playwright4life', () => {});
+            test('is another test #e2e #playwright4life', () => {
+              test.step('#wow', () => {});
+            });
           });
         });
       `,
@@ -264,7 +300,9 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           });
 
           test.describe('e2e tests #e4e', () => {
-            test('is another test #e2e #playwright4life', () => {});
+            test('is another test #e2e #playwright4life', () => {
+              test.step('#wow', () => {});
+            });
           });
         });
       `,
@@ -302,7 +340,9 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           });
 
           test.describe('e2e tests #e4e', () => {
-            test('is another test #e2e #playwright4life', () => {});
+            test('is another test #e2e #playwright4life', () => {
+              test.step('#wow', () => {});
+            });
           });
         });
       `,
@@ -334,7 +374,9 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
           });
 
           test.describe('e2e tests #e4e', () => {
-            test('is another test #e2e #playwright4life', () => {});
+            test('is another test #e2e #playwright4life', () => {
+              test.step('#wow', () => {});
+            });
           });
         });
       `,
@@ -409,6 +451,21 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
         { mustMatch: { describe: /#(?:unit|integration|e2e)/u.source } },
       ],
     },
+    {
+      code: 'test.step("the test", () => {});',
+      errors: [
+        {
+          column: 11,
+          data: {
+            functionName: 'step',
+            pattern: /#(?:unit|integration|e2e)/u,
+          },
+          line: 1,
+          messageId: 'mustMatch',
+        },
+      ],
+      options: [{ mustMatch: { step: /#(?:unit|integration|e2e)/u.source } }],
+    },
     {
       code: 'test.describe.skip("the test", () => {});',
       errors: [
@@ -503,6 +560,12 @@ runRuleTester('mustMatch & mustNotMatch options', rule, {
         { mustMatch: { describe: /#(?:unit|integration|e2e)/u.source } },
       ],
     },
+    {
+      code: 'test.step("correctly sets the value", () => {});',
+      options: [
+        { mustMatch: { describe: /#(?:unit|integration|e2e)/u.source } },
+      ],
+    },
     {
       code: javascript`
         test.describe('things to test', () => {
@@ -657,6 +720,17 @@ runRuleTester('title-must-be-string', rule, {
         },
       ],
     },
+    {
+      code: 'test.step(123, () => {});',
+      errors: [
+        {
+          column: 11,
+          line: 1,
+          messageId: 'titleMustBeString',
+        },
+      ],
+      options: [{ ignoreTypeOfStepName: false }],
+    },
     // Global aliases
     {
       code: 'it(String(/.+/), () => {});',
@@ -685,6 +759,7 @@ runRuleTester('title-must-be-string', rule, {
     'test.describe.skip("is a string", () => {});',
     'test.describe.skip(`${myFunc} is a string`, () => {});',
     'test.describe("is a string", () => {});',
+    'test.step(123, () => {});',
     {
       code: 'test.describe(String(/.+/), () => {});',
       options: [{ ignoreTypeOfDescribeName: true }],
@@ -811,6 +886,17 @@ runRuleTester('no-empty-title', rule, {
         },
       ],
     },
+    {
+      code: 'test.step(``, function () {})',
+      errors: [
+        {
+          column: 1,
+          data: { functionName: 'step' },
+          line: 1,
+          messageId: 'emptyTitle',
+        },
+      ],
+    },
     // Global aliases
     {
       code: 'it.describe("", function () {})',
@@ -840,6 +926,7 @@ runRuleTester('no-empty-title', rule, {
     'test.skip(`foo`, function () {})',
     'test(`${foo}`, function () {})',
     'test.fixme(`${foo}`, function () {})',
+    'test.step(`${foo}`, function () {})',
     // Global aliases
     {
       code: 'test.describe()',
@@ -969,6 +1056,21 @@ runRuleTester('no-accidental-space', rule, {
       errors: [{ column: 6, line: 1, messageId: 'accidentalSpace' }],
       output: 'test("foo", function () {})',
     },
+    {
+      code: 'test.step(` foo bar bang  `, function () {})',
+      errors: [{ column: 11, line: 1, messageId: 'accidentalSpace' }],
+      output: 'test.step(`foo bar bang`, function () {})',
+    },
+    {
+      code: 'test.step(" foo", function () {})',
+      errors: [{ column: 11, line: 1, messageId: 'accidentalSpace' }],
+      output: 'test.step("foo", function () {})',
+    },
+    {
+      code: 'test.step(" foo  ", function () {})',
+      errors: [{ column: 11, line: 1, messageId: 'accidentalSpace' }],
+      output: 'test.step("foo", function () {})',
+    },
     {
       code: javascript`
         test.describe(' foo', () => {
@@ -1013,6 +1115,8 @@ runRuleTester('no-accidental-space', rule, {
     'test("foo", function () {})',
     'test.describe()',
     'test.describe("foo", function () {})',
+    'test.step()',
+    'test.step("foo", function () {})',
     'test.only()',
     'test.only("foo", function () {})',
     javascript`
@@ -1135,6 +1239,37 @@ runRuleTester('no-duplicate-prefix test', rule, {
   ],
 })
 
+runRuleTester('no-duplicate-prefix step', rule, {
+  invalid: [
+    {
+      code: 'test.step("step foo", function () {})',
+      errors: [{ column: 11, line: 1, messageId: 'duplicatePrefix' }],
+      output: 'test.step("foo", function () {})',
+    },
+    {
+      code: 'test.step("step foo", function () {})',
+      errors: [{ column: 11, line: 1, messageId: 'duplicatePrefix' }],
+      output: 'test.step("foo", function () {})',
+    },
+    {
+      code: 'test.step("step foo", function () {})',
+      errors: [{ column: 11, line: 1, messageId: 'duplicatePrefix' }],
+      output: 'test.step("foo", function () {})',
+    },
+    {
+      code: "test.step('step foo', function () {})",
+      errors: [{ column: 11, line: 1, messageId: 'duplicatePrefix' }],
+      output: "test.step('foo', function () {})",
+    },
+    {
+      code: 'test.step(`step foo`, function () {})',
+      errors: [{ column: 11, line: 1, messageId: 'duplicatePrefix' }],
+      output: 'test.step(`foo`, function () {})',
+    },
+  ],
+  valid: ['test.step("foo", function () {})'],
+})
+
 runRuleTester('no-duplicate-prefix nested', rule, {
   invalid: [
     {
@@ -1176,6 +1311,23 @@ runRuleTester('no-duplicate-prefix nested', rule, {
         })
       `,
     },
+    {
+      code: javascript`
+        test.describe('foo', () => {
+          test('bar', () => {
+            test.step('step foobar', () => {})
+          })
+        })
+      `,
+      errors: [{ column: 15, line: 3, messageId: 'duplicatePrefix' }],
+      output: javascript`
+        test.describe('foo', () => {
+          test('bar', () => {
+            test.step('foobar', () => {})
+          })
+        })
+      `,
+    },
     // Global aliases
     {
       code: javascript`