ESLint for Testing: Rules, Plugins, and Enforcing Test Quality

ESLint for Testing: Rules, Plugins, and Enforcing Test Quality

Test code is production code. It runs in CI, it documents intent, and when it fails it needs to be debugged just like anything else. Yet most teams apply far more scrutiny to application code than to the tests that guard it. ESLint plugins designed specifically for testing frameworks bring the same automated quality enforcement to test files that you already rely on for your source code.

Why Test-Specific Linting Matters

General ESLint rules catch syntax errors and common JavaScript pitfalls. They don't know what makes a good test. A test can be syntactically perfect and still:

  • Never actually assert anything (expect called without a matcher)
  • Use test.only or fdescribe left over from debugging, blocking all other tests
  • Await a value from a synchronous function
  • Make assertions inside catch blocks that will pass even when the code throws
  • Use fireEvent directly in Testing Library when userEvent is the recommended API

These are the patterns that eslint-plugin-jest and eslint-plugin-testing-library exist to prevent.

Setting Up eslint-plugin-jest

Install the plugin:

npm install --save-dev eslint-plugin-jest

Configure it in your eslint.config.mjs (flat config format):

import jest from 'eslint-plugin-jest';

export default [
  // Your main config...
  {
    files: ['**/*.test.{js,ts,jsx,tsx}', '**/*.spec.{js,ts,jsx,tsx}'],
    plugins: { jest },
    languageOptions: {
      globals: jest.environments.globals.globals,
    },
    rules: {
      ...jest.configs.recommended.rules,
      // Additional rules beyond recommended
      'jest/consistent-test-it': ['error', { fn: 'it', withinDescribe: 'it' }],
      'jest/expect-expect': 'error',
      'jest/no-disabled-tests': 'warn',
      'jest/no-focused-tests': 'error',
      'jest/no-duplicate-hooks': 'error',
      'jest/no-test-return-statement': 'error',
      'jest/prefer-to-be': 'error',
      'jest/prefer-to-have-length': 'error',
      'jest/require-top-level-describe': 'error',
      'jest/valid-title': 'error',
    },
  },
];

For the legacy .eslintrc format:

{
  "overrides": [
    {
      "files": ["**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
      "extends": ["plugin:jest/recommended", "plugin:jest/style"],
      "plugins": ["jest"],
      "env": {
        "jest/globals": true
      },
      "rules": {
        "jest/no-focused-tests": "error",
        "jest/expect-expect": "error",
        "jest/no-duplicate-hooks": "error"
      }
    }
  ]
}

Key jest Rules That Prevent Real Bugs

jest/expect-expect — Fails tests with no expect calls. Empty tests pass silently, giving false confidence.

// Fails lint — no assertion
it('processes the request', async () => {
  await processRequest({ id: 1 });
});

// Passes lint
it('processes the request without throwing', async () => {
  await expect(processRequest({ id: 1 })).resolves.not.toThrow();
});

jest/no-focused-tests — Prevents test.only, it.only, describe.only, fdescribe, and fit from being committed. A focused test silently skips everything else in the file, potentially hiding failures.

jest/no-conditional-expect — Prevents expect calls inside if or try/catch. An expect inside catch will never run if the code doesn't throw, making the test vacuously pass.

// Fails lint — expect may never run
it('handles errors', async () => {
  try {
    await riskyOperation();
  } catch (error) {
    expect(error.message).toBe('Expected failure');
  }
});

// Correct pattern
it('handles errors', async () => {
  await expect(riskyOperation()).rejects.toThrow('Expected failure');
});

jest/no-standalone-expect — Catches expect calls outside of test functions, which can cause confusing errors.

jest/require-to-throw-message — Requires a message argument when using toThrow(). A bare toThrow() passes for any error, including ones you didn't expect.

Setting Up eslint-plugin-testing-library

If you use React Testing Library, eslint-plugin-testing-library enforces idiomatic patterns:

npm install --save-dev eslint-plugin-testing-library
import testingLibrary from 'eslint-plugin-testing-library';

export default [
  {
    files: ['**/*.test.{js,ts,jsx,tsx}'],
    plugins: { 'testing-library': testingLibrary },
    rules: {
      ...testingLibrary.configs.react.rules,
      // Prefer userEvent over fireEvent for user interactions
      'testing-library/prefer-user-event': 'error',
      // Avoid direct node access
      'testing-library/no-node-access': 'error',
      // Ensure cleanup runs
      'testing-library/no-render-in-setup': 'error',
      // Avoid unnecessary act() wrapping
      'testing-library/no-unnecessary-act': 'error',
      // Prefer findBy for async queries
      'testing-library/prefer-find-by': 'error',
      // No debug() calls committed
      'testing-library/no-debug': 'error',
    },
  },
];

testing-library/prefer-user-event is one of the most impactful rules. fireEvent dispatches raw DOM events; userEvent simulates actual user interactions including all intermediate events (focus, hover, keydown/up). Code that works with fireEvent but breaks with userEvent usually has a bug in its event handling.

// Flagged by lint
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));

// Preferred
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));

testing-library/no-await-sync-query — Prevents awaiting synchronous queries like getBy*. These functions throw synchronously if an element isn't found; awaiting them doesn't help and obscures async bugs.

Custom Rules for Project-Specific Patterns

Sometimes you have project-specific anti-patterns worth catching. ESLint's no-restricted-syntax rule lets you ban specific AST patterns without writing a custom plugin.

Ban a known problematic import pattern:

{
  "rules": {
    "no-restricted-imports": ["error", {
      "patterns": [{
        "group": ["../../../*"],
        "message": "Avoid deep relative imports in tests. Use path aliases instead."
      }]
    }]
  }
}

Ban a specific anti-pattern using AST selectors:

{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.object.name='wrapper'][callee.property.name='update']",
        "message": "wrapper.update() is deprecated in Enzyme. Use wrapper.setProps() or re-render instead."
      },
      {
        "selector": "MemberExpression[object.name='console'][property.name='log']",
        "message": "Remove console.log from tests before committing."
      }
    ]
  }
}

Enforcing in CI

ESLint should run in CI as a separate step from tests, with a non-zero exit code on violations. This ensures linting failures block PRs independently of test results.

# .github/workflows/lint.yml
name: Lint

on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Lint source files
        run: npx eslint src --ext .ts,.tsx --max-warnings 0
      - name: Lint test files
        run: npx eslint src --ext .test.ts,.test.tsx,.spec.ts,.spec.tsx --max-warnings 0

The --max-warnings 0 flag treats warnings as errors. If you want to allow some warnings locally but not in CI, use an environment-conditional config:

const isCI = process.env.CI === 'true';

export default [
  {
    rules: {
      'jest/no-disabled-tests': isCI ? 'error' : 'warn',
    },
  },
];

Auto-Fix Patterns

Many test lint violations are auto-fixable. Run ESLint with --fix to apply them:

npx eslint src --ext .test.ts,.spec.ts --fix

Fixable rules include jest/prefer-to-be, jest/prefer-to-have-length, jest/consistent-test-it, and most testing-library/prefer-* rules. Running auto-fix as part of a pre-commit hook keeps violations from accumulating:

// package.json
{
  "lint-staged": {
    "*.{test,spec}.{ts,tsx}": [
      "eslint --fix",
      "eslint --max-warnings 0"
    ]
  }
}

Set up lint-staged with Husky to run this on every commit:

npm install --save-dev husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit

Separating Test and Source Configs

A common mistake is applying test-specific rules to source files or vice versa. Keep them cleanly separated using ESLint's overrides (legacy) or separate config objects (flat config) scoped to test file globs. Source files shouldn't have jest/globals in their environment, and test files shouldn't need rules about React hooks or component structure.

This separation also lets you be stricter in test files about things that don't matter in source — like banning console.log, requiring descriptive test titles, or mandating the it keyword consistently.

Linting test files isn't bureaucracy. It's the same discipline you apply to application code, applied to the code that guards everything else.

Read more

Start now free