🛡️ Never Commit Broken Code Again: A Guide to ESLint and Husky in Playwright

Ivan DavidovIvan Davidov
10 min read

In the world of automated testing, consistency is king. When you're working on a team, it's easy for different coding styles and small mistakes to creep into the codebase. This leads to messy code, broken tests, and frustrating code reviews. But what if you could automatically enforce quality standards before bad code ever gets committed?

This guide is for any Automation QA who wants to build a safety net for their Playwright project. We'll focus on how even junior QAs can implement powerful checks to ensure every team member adheres to the same high standards.

By the end of this tutorial, you will have a fully automated system that lints, formats, and validates your code every time you try to commit.

Ready to build your fortress? Let's get started. 🏰


📝Prerequisites

Before we begin, you should have a basic Playwright project already set up. This guide assumes you have Node.js and npm installed and ready to go. You can check Playwright project Initial Setup.


🛠️The Dream Team: Our Tooling Explained

We'll be using a powerful combination of four tools to create our pre-commit safety net. Here's a quick rundown of what each one does:

  • ESLint 🧐: A static analysis tool that scans your code to find and report on patterns, potential bugs, and stylistic errors based on a configurable set of rules.

  • Prettier ✨: An opinionated code formatter. It enforces a consistent style by parsing your code and re-printing it with its own rules, saving you from any formatting debates.

  • Husky 🐕‍🦺: A tool that makes it incredibly simple to work with Git hooks. We'll use it to set up a "pre-commit" hook, which is a script that runs right before a commit is finalized.

  • lint-staged 🚫: The perfect partner for Husky. It allows you to run linters and formatters against only the files that have been staged in Git, making the process fast and efficient.

Now, let's integrate them into your Playwright project.


🪜Step-by-Step Implementation Guide

Follow these steps carefully to build your automated checks.

Step 1: 📦Install Your Dependencies

First, we need to add our new tools to the project. Below is the command that will install all the dependencies for our case:

npm install --save-dev @typescript-eslint/eslint-plugin@^8.34.1 @typescript-eslint/parser@^8.34.1 eslint@^9.29.0 eslint-config-prettier@^10.1.5 eslint-define-config@^2.1.0 eslint-plugin-playwright@^2.2.0 eslint-plugin-prettier@^5.5.0 husky@^9.1.7 jiti@^2.4.2 lint-staged@^16.1.2

Open your package.json file and check the following packages to your devDependencies (most probably your versions will be newer):

"devDependencies": {
        "@playwright/test": "^1.53.2",
        "@types/node": "^24.0.12",
        "@typescript-eslint/eslint-plugin": "^8.36.0",
        "@typescript-eslint/parser": "^8.36.0",
        "eslint": "^9.30.1",
        "eslint-config-prettier": "^10.1.5",
        "eslint-define-config": "^2.1.0",
        "eslint-plugin-playwright": "^2.2.0",
        "eslint-plugin-prettier": "^5.5.1",
        "husky": "^9.1.7",
        "jiti": "^2.4.2",
        "lint-staged": "^16.1.2"
    },

Step 2: ✨Configure Prettier for Consistent Formatting

  • Install Prettier extention in your IDE:

Prettier

  • Change the “Default Formatter” and “Format On Save” options in your IDE

Press Ctrl + Shift + P and type “Preferences: Open Settings (UI)”. Open it and In the search bar, type “format:. Make the changes from the screenshot below:

Settings

Create a new file in your project's root directory named .prettierrc. This file tells Prettier how you want your code to be formatted.

{
    "semi": true,
    "tabWidth": 4,
    "useTabs": false,
    "printWidth": 80,
    "singleQuote": true,
    "trailingComma": "es5",
    "bracketSpacing": true,
    "arrowParens": "always",
    "proseWrap": "preserve"
}

Explanation of each Prettier option:

  • semi: (true) Always add a semicolon at the end of every statement.

  • tabWidth: (4) Use 4 spaces per indentation level.

  • useTabs: (false) Indent lines with spaces instead of tabs.

  • printWidth: (80) Wrap lines at 80 characters for better readability.

  • singleQuote: (true) Use single quotes instead of double quotes wherever possible.

  • trailingComma: ("es5") Add trailing commas wherever valid in ES5 (objects, arrays, etc.).

  • bracketSpacing: (true) Print spaces between brackets in object literals (e.g., { foo: bar }).

  • arrowParens: ("always") Always include parentheses around arrow function parameters, even if there is only one parameter.

  • proseWrap: ("preserve") Do not change wrapping in markdown text; respect the input's line breaks.

Step 3: 📝Set Up Your TypeScript Configuration

If you don't already have one, create a tsconfig.json file in your root directory. This file specifies the root files and the compiler options required to compile a TypeScript project.

{
    "compilerOptions": {
        "target": "ESNext",
        "module": "NodeNext",
        "lib": ["ESNext", "DOM"],
        "moduleResolution": "NodeNext",
        "esModuleInterop": true,
        "allowJs": true,
        "checkJs": false,
        "outDir": "./dist",
        "rootDir": ".",
        "strict": true,
        "noImplicitAny": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "resolveJsonModule": true,
        "types": ["node", "playwright"]
    },
    "include": [
        "*.ts",
        "*.mts",
        "tests/**/*.ts",
        "fixtures/**/*.ts",
        "pages/**/*.ts",
        "helpers/**/*.ts",
        "enums/**/*.ts"
    ],
    "exclude": ["node_modules", "dist", "playwright-report", "test-results"]
}

The include and exclude arrays are crucial here—they tell the TypeScript compiler exactly which files to check and which to ignore.

Step 4: 📖Create the ESLint Rulebook

This is where the magic happens. Create a file named eslint.config.mts in your root directory. This file will contain all the rules for ensuring code quality.

import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import prettierPlugin from 'eslint-plugin-prettier';
import playwright from 'eslint-plugin-playwright';

const prettierConfig = {
    semi: true,
    tabWidth: 4,
    useTabs: false,
    printWidth: 80,
    singleQuote: true,
    trailingComma: 'es5',
    bracketSpacing: true,
    arrowParens: 'always',
    proseWrap: 'preserve',
};

const config = [
    {
        ignores: ['node_modules', 'dist', 'playwright-report', 'test-results'],
    },
    {
        files: ['**/*.ts', '**/*.tsx'],
        languageOptions: {
            parser: tsParser,
            parserOptions: {
                ecmaVersion: 2020,
                sourceType: 'module',
                project: ['./tsconfig.json'],
                tsconfigRootDir: __dirname,
            },
        },
        plugins: {
            '@typescript-eslint': tseslint,
            prettier: prettierPlugin,
            playwright,
        },
        rules: {
            ...((tseslint.configs.recommended as any).rules ?? {}),
            ...((playwright.configs['flat/recommended'] as any).rules ?? {}),
            'prettier/prettier': ['error', prettierConfig],
            '@typescript-eslint/explicit-function-return-type': 'error',
            '@typescript-eslint/no-explicit-any': 'error',
            '@typescript-eslint/no-unused-vars': [
                'error',
                { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
            ],
            'no-console': 'error',
            'prefer-const': 'error',
            '@typescript-eslint/no-inferrable-types': 'error',
            '@typescript-eslint/no-empty-function': 'error',
            '@typescript-eslint/no-floating-promises': 'error',
            'playwright/missing-playwright-await': 'error',
            'playwright/no-page-pause': 'error',
            'playwright/no-useless-await': 'error',
            'playwright/no-skipped-test': 'error',
        },
    },
];

export default config;

What do all these rules do?

Your ESLint config enforces a comprehensive set of best practices and code quality standards. Here's what each part does:

  • ...tseslint.configs.recommended: This preset from @typescript-eslint enables a wide range of rules that catch common bugs and bad practices in TypeScript code. It enforces things like avoiding unsafe any types, preventing unused variables, requiring proper use of promises, and more. These rules help ensure your TypeScript code is robust, maintainable, and less error-prone.

  • ...playwright.configs['flat/recommended']: This preset from eslint-plugin-playwright enforces best practices for Playwright test code. It catches mistakes like missing await on Playwright actions, using forbidden test annotations (like .only or .skip), unsafe selectors, and more. This helps keep your tests reliable and consistent.

  • 'prettier/prettier': ['error', prettierConfig]: Enforces code formatting according to your Prettier settings. Any formatting issues will be reported as errors, ensuring a consistent code style across your project.

  • '@typescript-eslint/explicit-function-return-type': 'error': Requires all functions to have an explicit return type. This improves code readability and helps catch bugs where a function might return an unexpected type.

  • '@typescript-eslint/no-explicit-any': 'error': Disallows the use of the any type. This ensures you use more precise types, making your code safer and easier to maintain.

  • '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]: Prevents unused variables and function arguments, except those starting with an underscore (which are often intentionally unused). This helps keep your code clean and free of clutter.

  • 'no-console': 'error': Disallows console.log and similar statements. This prevents accidental logging in production code, which can be unprofessional or leak sensitive information.

  • 'prefer-const': 'error': Requires variables that are never reassigned after declaration to be declared with const. This helps prevent accidental reassignment and makes code intent clearer.

  • '@typescript-eslint/no-inferrable-types': 'error': Disallows explicit type declarations for variables or parameters initialized to a number, string, or boolean. This keeps code cleaner and leverages TypeScript's type inference.

  • '@typescript-eslint/no-empty-function': 'error': Disallows empty functions. Empty functions are often a sign of incomplete or placeholder code and should be avoided.

  • '@typescript-eslint/no-floating-promises': 'error': Requires that Promises are handled appropriately (with await or .then()). This prevents unhandled promise rejections and ensures async code is reliable.

  • 'playwright/missing-playwright-await': 'error': Ensures that all Playwright actions (like page.click()) are properly awaited. Missing await is a common cause of flaky tests.

  • 'playwright/no-page-pause': 'error': Disallows the use of page.pause(), which is meant for debugging and should not be left in committed test code.

  • 'playwright/no-useless-await': 'error': Prevents unnecessary use of await on synchronous Playwright methods, keeping your code clean and efficient.

  • 'playwright/no-skipped-test': 'error': Disallows skipping tests using .skip. This ensures that all tests are run and nothing is accidentally left out.

How do these rules help?

  • Rules set to 'error' will block a commit, forcing you to fix the issue before your code can be merged.

By combining these rules, your project is protected from common mistakes, code smells, and inconsistent styles—making your codebase more reliable, readable, and professional.

Step 5: 🔗Connect the Tools with lint-staged

Now, let's tell lint-staged what to do. Add the following block to your package.json at the root level (just after the devDependencies):

"lint-staged": {
  "*.{ts,tsx,js,jsx}": [
    "npx eslint --fix",
    "npx prettier --write"
  ]
}

This configuration tells lint-staged: "For any staged TypeScript or JavaScript file, first run ESLint to automatically fix what it can, and then run Prettier to format the code."

Step 6: 🐕‍🦺Automate the Trigger with Husky

Finally, let's make this all run automatically before each commit using Husky.

First, initialize Husky:

npx husky init

This command creates a .husky directory in your project.

⚠️Next, remove all files, except _/pre-commit and add the following script to it. This is the hook that Git will run.

Update the content of the _/pre-commit to:

#!/bin/sh
npx lint-staged

This tiny script tells Git to run lint-staged before every commit. And that's it! Your setup is complete. 🎯


🚀See It In Action: 🤯Let's Break Some Rules

The best way to appreciate our new safety net is to see it in action. Let's try to commit some "bad" code.

Create a new test file named tests/bad-test-example.spec.ts. Paste the following code, which intentionally violates several of our rules:

// tests/bad-test-example.spec.ts
import { test, expect } from '@playwright/test';

// Rule violation: No explicit return type
function addNumbers(a: number, b: number) {
    //Rule violation: No explicit return type
    const unusedVar = 'I do nothing'; // Rule violation: Unused variable
    return a + b;
}

test('bad example test', async ({ page }) => {
    console.log('This should not be here!'); // Rule violation: no-console

    await page.goto('https://playwright.dev/');

    page.getByRole('button', { name: 'Get Started' }).click(); //Rule violation: Missing 'await' for a Playwright action

    // Rule violation: Missing 'await' for a Playwright action
    page.getByLabel('Search').click();

    await page.getByPlaceholder('Search docs').fill('assertions');

    // Rule violation: Using page.pause()
    await page.pause();

    await expect(page.getByText('Writing assertions')).toBeVisible(); //Rule violation: Missing 'await'

    let result;
    await test.step('test', async () => {
        result = addNumbers(1, 2); //Rule violation: no-explicit-any
    });
    console.log(result); //Rule violation: no-console
});

Now, try to commit this file:

git add .
git commit -m "feat: add broken test file"

What happens? Your commit will be REJECTED! 🛑

You will see an output in your terminal from ESLint, listing all the errors it found:

  • 🚫The no-console error.

  • 🏷️The missing function return type.

  • ⏳The missing await on page.getByRole('button', { name: 'Get Started' }).click(); and page.getByLabel('Search').click();.

  • ⏸️The use of page.pause().

  • 🗑️The unused variable.

lint-staged will prevent the commit from completing until you fix these errors. Prettier may have also fixed some formatting issues automatically.


The Payoff: Better Code, Happier Teams

You've just implemented a powerful, automated system that acts as a guardian for your codebase. This small setup provides enormous long-term benefits:

  • 🧹Better Code Quality: The code is cleaner, more readable, and free of common errors.

  • 👥Stick to Team Rules: Everyone on the team is automatically held to the same coding standards, ensuring consistency across the entire project.

  • Faster Code Reviews: Reviewers can focus on the logic of the code, not on trivial formatting or style issues.

By enforcing these standards automatically, you free up mental energy to focus on what really matters: writing great, reliable tests. 🧠


🏁Conclusion

Congratulations! 🎊 You've just built a powerful safety net for your Playwright project. This setup ensures that your codebase stays clean, readable, and free of common errors.


🙏🏻 Thank you for reading! Building robust, scalable automation frameworks is a journey best taken together. If you found this article helpful, consider joining a growing community of QA professionals 🚀 who are passionate about mastering modern testing.

Join the community and get the latest articles and tips by signing up for the newsletter.

0
Subscribe to my newsletter

Read articles from Ivan Davidov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ivan Davidov
Ivan Davidov

Automation QA Engineer, ISTQB CTFL, PSM I, helping teams improve the quality of the product they deliver to their customers. • Led the development of end-to-end (E2E) and API testing frameworks from scratch using Playwright and TypeScript, ensuring robust and scalable test automation solutions. • Integrated automated tests into CI/CD pipelines to enhance continuous integration and delivery. • Created comprehensive test strategies and plans to improve test coverage and effectiveness. • Designed performance testing frameworks using k6 to optimize system scalability and reliability. • Provided accurate project estimations for QA activities, aiding effective project planning. • Worked with development and product teams to align testing efforts with business and technical requirements. • Improved QA processes, tools, and methodologies for increased testing efficiency. • Domain experience: banking, pharmaceutical and civil engineering. Bringing over 3 year of experience in Software Engineering, 7 years of experience in Civil engineering project management, team leadership and project design, to the table, I champion a disciplined, results-driven approach, boasting a record of more than 35 successful projects.