From dd0b9b36cdd9b8315a50f94761456ee3292156f6 Mon Sep 17 00:00:00 2001 From: Nicolas GILLOT Date: Sat, 1 Nov 2025 14:19:31 +0100 Subject: [PATCH] feat: create new pair-to-have-been-called-assertions rule --- README.md | 129 ++--- .../pair-to-have-been-called-assertions.md | 142 +++++ .../__snapshots__/rules.test.ts.snap | 2 + src/__tests__/rules.test.ts | 2 +- ...air-to-have-been-called-assertions.test.ts | 490 ++++++++++++++++++ .../pair-to-have-been-called-assertions.ts | 396 ++++++++++++++ 6 files changed, 1096 insertions(+), 65 deletions(-) create mode 100644 docs/rules/pair-to-have-been-called-assertions.md create mode 100644 src/rules/__tests__/pair-to-have-been-called-assertions.test.ts create mode 100644 src/rules/pair-to-have-been-called-assertions.ts diff --git a/README.md b/README.md index 79064c319..3ed1ce1ad 100644 --- a/README.md +++ b/README.md @@ -323,70 +323,71 @@ Automatically fixable by the Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). -| Name                              | Description | 💼 | ⚠️ | 🔧 | 💡 | -| :----------------------------------------------------------------------------------- | :------------------------------------------------------------------------ | :-- | :-- | :-- | :-- | -| [consistent-test-it](docs/rules/consistent-test-it.md) | Enforce `test` and `it` usage conventions | | | 🔧 | | -| [expect-expect](docs/rules/expect-expect.md) | Enforce assertion to be made in a test body | | ✅ | | | -| [max-expects](docs/rules/max-expects.md) | Enforces a maximum number assertion calls in a test body | | | | | -| [max-nested-describe](docs/rules/max-nested-describe.md) | Enforces a maximum depth to nested describe calls | | | | | -| [no-alias-methods](docs/rules/no-alias-methods.md) | Disallow alias methods | ✅ | | 🔧 | | -| [no-commented-out-tests](docs/rules/no-commented-out-tests.md) | Disallow commented out tests | | ✅ | | | -| [no-conditional-expect](docs/rules/no-conditional-expect.md) | Disallow calling `expect` conditionally | ✅ | | | | -| [no-conditional-in-test](docs/rules/no-conditional-in-test.md) | Disallow conditional logic in tests | | | | | -| [no-confusing-set-timeout](docs/rules/no-confusing-set-timeout.md) | Disallow confusing usages of jest.setTimeout | | | | | -| [no-deprecated-functions](docs/rules/no-deprecated-functions.md) | Disallow use of deprecated functions | ✅ | | 🔧 | | -| [no-disabled-tests](docs/rules/no-disabled-tests.md) | Disallow disabled tests | | ✅ | | | -| [no-done-callback](docs/rules/no-done-callback.md) | Disallow using a callback in asynchronous tests and hooks | ✅ | | | 💡 | -| [no-duplicate-hooks](docs/rules/no-duplicate-hooks.md) | Disallow duplicate setup and teardown hooks | | | | | -| [no-export](docs/rules/no-export.md) | Disallow using `exports` in files containing tests | ✅ | | | | -| [no-focused-tests](docs/rules/no-focused-tests.md) | Disallow focused tests | ✅ | | | 💡 | -| [no-hooks](docs/rules/no-hooks.md) | Disallow setup and teardown hooks | | | | | -| [no-identical-title](docs/rules/no-identical-title.md) | Disallow identical titles | ✅ | | | | -| [no-interpolation-in-snapshots](docs/rules/no-interpolation-in-snapshots.md) | Disallow string interpolation inside snapshots | ✅ | | | | -| [no-jasmine-globals](docs/rules/no-jasmine-globals.md) | Disallow Jasmine globals | ✅ | | 🔧 | | -| [no-large-snapshots](docs/rules/no-large-snapshots.md) | Disallow large snapshots | | | | | -| [no-mocks-import](docs/rules/no-mocks-import.md) | Disallow manually importing from `__mocks__` | ✅ | | | | -| [no-restricted-jest-methods](docs/rules/no-restricted-jest-methods.md) | Disallow specific `jest.` methods | | | | | -| [no-restricted-matchers](docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers | | | | | -| [no-standalone-expect](docs/rules/no-standalone-expect.md) | Disallow using `expect` outside of `it` or `test` blocks | ✅ | | | | -| [no-test-prefixes](docs/rules/no-test-prefixes.md) | Require using `.only` and `.skip` over `f` and `x` | ✅ | | 🔧 | | -| [no-test-return-statement](docs/rules/no-test-return-statement.md) | Disallow explicitly returning from tests | | | | | -| [no-untyped-mock-factory](docs/rules/no-untyped-mock-factory.md) | Disallow using `jest.mock()` factories without an explicit type parameter | | | 🔧 | | -| [padding-around-after-all-blocks](docs/rules/padding-around-after-all-blocks.md) | Enforce padding around `afterAll` blocks | | | 🔧 | | -| [padding-around-after-each-blocks](docs/rules/padding-around-after-each-blocks.md) | Enforce padding around `afterEach` blocks | | | 🔧 | | -| [padding-around-all](docs/rules/padding-around-all.md) | Enforce padding around Jest functions | | | 🔧 | | -| [padding-around-before-all-blocks](docs/rules/padding-around-before-all-blocks.md) | Enforce padding around `beforeAll` blocks | | | 🔧 | | -| [padding-around-before-each-blocks](docs/rules/padding-around-before-each-blocks.md) | Enforce padding around `beforeEach` blocks | | | 🔧 | | -| [padding-around-describe-blocks](docs/rules/padding-around-describe-blocks.md) | Enforce padding around `describe` blocks | | | 🔧 | | -| [padding-around-expect-groups](docs/rules/padding-around-expect-groups.md) | Enforce padding around `expect` groups | | | 🔧 | | -| [padding-around-test-blocks](docs/rules/padding-around-test-blocks.md) | Enforce padding around `test` and `it` blocks | | | 🔧 | | -| [prefer-called-with](docs/rules/prefer-called-with.md) | Suggest using `toBeCalledWith()` or `toHaveBeenCalledWith()` | | | | | -| [prefer-comparison-matcher](docs/rules/prefer-comparison-matcher.md) | Suggest using the built-in comparison matchers | | | 🔧 | | -| [prefer-each](docs/rules/prefer-each.md) | Prefer using `.each` rather than manual loops | | | | | -| [prefer-ending-with-an-expect](docs/rules/prefer-ending-with-an-expect.md) | Prefer having the last statement in a test be an assertion | | | | | -| [prefer-equality-matcher](docs/rules/prefer-equality-matcher.md) | Suggest using the built-in equality matchers | | | | 💡 | -| [prefer-expect-assertions](docs/rules/prefer-expect-assertions.md) | Suggest using `expect.assertions()` OR `expect.hasAssertions()` | | | | 💡 | -| [prefer-expect-resolves](docs/rules/prefer-expect-resolves.md) | Prefer `await expect(...).resolves` over `expect(await ...)` syntax | | | 🔧 | | -| [prefer-hooks-in-order](docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | | | -| [prefer-hooks-on-top](docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | | | -| [prefer-importing-jest-globals](docs/rules/prefer-importing-jest-globals.md) | Prefer importing Jest globals | | | 🔧 | | -| [prefer-jest-mocked](docs/rules/prefer-jest-mocked.md) | Prefer `jest.mocked()` over `fn as jest.Mock` | | | 🔧 | | -| [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | | 🔧 | | -| [prefer-mock-promise-shorthand](docs/rules/prefer-mock-promise-shorthand.md) | Prefer mock resolved/rejected shorthands for promises | | | 🔧 | | -| [prefer-snapshot-hint](docs/rules/prefer-snapshot-hint.md) | Prefer including a hint with external snapshots | | | | | -| [prefer-spy-on](docs/rules/prefer-spy-on.md) | Suggest using `jest.spyOn()` | | | 🔧 | | -| [prefer-strict-equal](docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | | 💡 | -| [prefer-to-be](docs/rules/prefer-to-be.md) | Suggest using `toBe()` for primitive literals | 🎨 | | 🔧 | | -| [prefer-to-contain](docs/rules/prefer-to-contain.md) | Suggest using `toContain()` | 🎨 | | 🔧 | | -| [prefer-to-have-length](docs/rules/prefer-to-have-length.md) | Suggest using `toHaveLength()` | 🎨 | | 🔧 | | -| [prefer-todo](docs/rules/prefer-todo.md) | Suggest using `test.todo` | | | 🔧 | | -| [require-hook](docs/rules/require-hook.md) | Require setup and teardown code to be within a hook | | | | | -| [require-to-throw-message](docs/rules/require-to-throw-message.md) | Require a message for `toThrow()` | | | | | -| [require-top-level-describe](docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `describe` block | | | | | -| [valid-describe-callback](docs/rules/valid-describe-callback.md) | Enforce valid `describe()` callback | ✅ | | | | -| [valid-expect](docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | 🔧 | | -| [valid-expect-in-promise](docs/rules/valid-expect-in-promise.md) | Require promises that have expectations in their chain to be valid | ✅ | | | | -| [valid-title](docs/rules/valid-title.md) | Enforce valid titles | ✅ | | 🔧 | | +| Name                                | Description | 💼 | ⚠️ | 🔧 | 💡 | +| :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------ | :-- | :-- | :-- | :-- | +| [consistent-test-it](docs/rules/consistent-test-it.md) | Enforce `test` and `it` usage conventions | | | 🔧 | | +| [expect-expect](docs/rules/expect-expect.md) | Enforce assertion to be made in a test body | | ✅ | | | +| [max-expects](docs/rules/max-expects.md) | Enforces a maximum number assertion calls in a test body | | | | | +| [max-nested-describe](docs/rules/max-nested-describe.md) | Enforces a maximum depth to nested describe calls | | | | | +| [no-alias-methods](docs/rules/no-alias-methods.md) | Disallow alias methods | ✅ | | 🔧 | | +| [no-commented-out-tests](docs/rules/no-commented-out-tests.md) | Disallow commented out tests | | ✅ | | | +| [no-conditional-expect](docs/rules/no-conditional-expect.md) | Disallow calling `expect` conditionally | ✅ | | | | +| [no-conditional-in-test](docs/rules/no-conditional-in-test.md) | Disallow conditional logic in tests | | | | | +| [no-confusing-set-timeout](docs/rules/no-confusing-set-timeout.md) | Disallow confusing usages of jest.setTimeout | | | | | +| [no-deprecated-functions](docs/rules/no-deprecated-functions.md) | Disallow use of deprecated functions | ✅ | | 🔧 | | +| [no-disabled-tests](docs/rules/no-disabled-tests.md) | Disallow disabled tests | | ✅ | | | +| [no-done-callback](docs/rules/no-done-callback.md) | Disallow using a callback in asynchronous tests and hooks | ✅ | | | 💡 | +| [no-duplicate-hooks](docs/rules/no-duplicate-hooks.md) | Disallow duplicate setup and teardown hooks | | | | | +| [no-export](docs/rules/no-export.md) | Disallow using `exports` in files containing tests | ✅ | | | | +| [no-focused-tests](docs/rules/no-focused-tests.md) | Disallow focused tests | ✅ | | | 💡 | +| [no-hooks](docs/rules/no-hooks.md) | Disallow setup and teardown hooks | | | | | +| [no-identical-title](docs/rules/no-identical-title.md) | Disallow identical titles | ✅ | | | | +| [no-interpolation-in-snapshots](docs/rules/no-interpolation-in-snapshots.md) | Disallow string interpolation inside snapshots | ✅ | | | | +| [no-jasmine-globals](docs/rules/no-jasmine-globals.md) | Disallow Jasmine globals | ✅ | | 🔧 | | +| [no-large-snapshots](docs/rules/no-large-snapshots.md) | Disallow large snapshots | | | | | +| [no-mocks-import](docs/rules/no-mocks-import.md) | Disallow manually importing from `__mocks__` | ✅ | | | | +| [no-restricted-jest-methods](docs/rules/no-restricted-jest-methods.md) | Disallow specific `jest.` methods | | | | | +| [no-restricted-matchers](docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers | | | | | +| [no-standalone-expect](docs/rules/no-standalone-expect.md) | Disallow using `expect` outside of `it` or `test` blocks | ✅ | | | | +| [no-test-prefixes](docs/rules/no-test-prefixes.md) | Require using `.only` and `.skip` over `f` and `x` | ✅ | | 🔧 | | +| [no-test-return-statement](docs/rules/no-test-return-statement.md) | Disallow explicitly returning from tests | | | | | +| [no-untyped-mock-factory](docs/rules/no-untyped-mock-factory.md) | Disallow using `jest.mock()` factories without an explicit type parameter | | | 🔧 | | +| [padding-around-after-all-blocks](docs/rules/padding-around-after-all-blocks.md) | Enforce padding around `afterAll` blocks | | | 🔧 | | +| [padding-around-after-each-blocks](docs/rules/padding-around-after-each-blocks.md) | Enforce padding around `afterEach` blocks | | | 🔧 | | +| [padding-around-all](docs/rules/padding-around-all.md) | Enforce padding around Jest functions | | | 🔧 | | +| [padding-around-before-all-blocks](docs/rules/padding-around-before-all-blocks.md) | Enforce padding around `beforeAll` blocks | | | 🔧 | | +| [padding-around-before-each-blocks](docs/rules/padding-around-before-each-blocks.md) | Enforce padding around `beforeEach` blocks | | | 🔧 | | +| [padding-around-describe-blocks](docs/rules/padding-around-describe-blocks.md) | Enforce padding around `describe` blocks | | | 🔧 | | +| [padding-around-expect-groups](docs/rules/padding-around-expect-groups.md) | Enforce padding around `expect` groups | | | 🔧 | | +| [padding-around-test-blocks](docs/rules/padding-around-test-blocks.md) | Enforce padding around `test` and `it` blocks | | | 🔧 | | +| [pair-to-have-been-called-assertions](docs/rules/pair-to-have-been-called-assertions.md) | Require `toHaveBeenCalledTimes()` when using `toHaveBeenCalledWith()` | | | 🔧 | | +| [prefer-called-with](docs/rules/prefer-called-with.md) | Suggest using `toBeCalledWith()` or `toHaveBeenCalledWith()` | | | | | +| [prefer-comparison-matcher](docs/rules/prefer-comparison-matcher.md) | Suggest using the built-in comparison matchers | | | 🔧 | | +| [prefer-each](docs/rules/prefer-each.md) | Prefer using `.each` rather than manual loops | | | | | +| [prefer-ending-with-an-expect](docs/rules/prefer-ending-with-an-expect.md) | Prefer having the last statement in a test be an assertion | | | | | +| [prefer-equality-matcher](docs/rules/prefer-equality-matcher.md) | Suggest using the built-in equality matchers | | | | 💡 | +| [prefer-expect-assertions](docs/rules/prefer-expect-assertions.md) | Suggest using `expect.assertions()` OR `expect.hasAssertions()` | | | | 💡 | +| [prefer-expect-resolves](docs/rules/prefer-expect-resolves.md) | Prefer `await expect(...).resolves` over `expect(await ...)` syntax | | | 🔧 | | +| [prefer-hooks-in-order](docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | | | +| [prefer-hooks-on-top](docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | | | +| [prefer-importing-jest-globals](docs/rules/prefer-importing-jest-globals.md) | Prefer importing Jest globals | | | 🔧 | | +| [prefer-jest-mocked](docs/rules/prefer-jest-mocked.md) | Prefer `jest.mocked()` over `fn as jest.Mock` | | | 🔧 | | +| [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | | 🔧 | | +| [prefer-mock-promise-shorthand](docs/rules/prefer-mock-promise-shorthand.md) | Prefer mock resolved/rejected shorthands for promises | | | 🔧 | | +| [prefer-snapshot-hint](docs/rules/prefer-snapshot-hint.md) | Prefer including a hint with external snapshots | | | | | +| [prefer-spy-on](docs/rules/prefer-spy-on.md) | Suggest using `jest.spyOn()` | | | 🔧 | | +| [prefer-strict-equal](docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | | 💡 | +| [prefer-to-be](docs/rules/prefer-to-be.md) | Suggest using `toBe()` for primitive literals | 🎨 | | 🔧 | | +| [prefer-to-contain](docs/rules/prefer-to-contain.md) | Suggest using `toContain()` | 🎨 | | 🔧 | | +| [prefer-to-have-length](docs/rules/prefer-to-have-length.md) | Suggest using `toHaveLength()` | 🎨 | | 🔧 | | +| [prefer-todo](docs/rules/prefer-todo.md) | Suggest using `test.todo` | | | 🔧 | | +| [require-hook](docs/rules/require-hook.md) | Require setup and teardown code to be within a hook | | | | | +| [require-to-throw-message](docs/rules/require-to-throw-message.md) | Require a message for `toThrow()` | | | | | +| [require-top-level-describe](docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `describe` block | | | | | +| [valid-describe-callback](docs/rules/valid-describe-callback.md) | Enforce valid `describe()` callback | ✅ | | | | +| [valid-expect](docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | 🔧 | | +| [valid-expect-in-promise](docs/rules/valid-expect-in-promise.md) | Require promises that have expectations in their chain to be valid | ✅ | | | | +| [valid-title](docs/rules/valid-title.md) | Enforce valid titles | ✅ | | 🔧 | | ### Requires Type Checking diff --git a/docs/rules/pair-to-have-been-called-assertions.md b/docs/rules/pair-to-have-been-called-assertions.md new file mode 100644 index 000000000..6b39d7bad --- /dev/null +++ b/docs/rules/pair-to-have-been-called-assertions.md @@ -0,0 +1,142 @@ +# Require `toHaveBeenCalledTimes()` when using `toHaveBeenCalledWith()` (`pair-to-have-been-called-assertions`) + +🔧 This rule is automatically fixable by the +[`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +## Rule Details + +When testing mock functions, developers often use `toHaveBeenCalledWith()`, +`toBeCalledWith()`, `toHaveBeenNthCalledWith()`, `toBeNthCalledWith()`, +`toHaveBeenLastCalledWith()`, or `toBeLastCalledWith()` to verify that a +function was called with specific arguments. However, without also checking the +call count using `toHaveBeenCalledTimes()` or `toBeCalledTimes()`, the test can +pass even if the mock was called more times than expected, potentially masking +bugs. + +This rule requires that whenever you use these matchers with arguments, you must +also use the corresponding `toHaveBeenCalledTimes()` or `toBeCalledTimes()` +matcher for the same mock function to ensure an exact call count. + +### Benefits + +- **Prevents false positives**: Ensures tests fail when a mock is called more + times than expected +- **Makes test intentions explicit**: Clearly documents how many times a + function should be called +- **Improves test reliability**: Catches unexpected behavior where functions are + called multiple times +- **Follows testing best practices**: Encourages complete and precise assertions + +## Examples + +### Incorrect + +```js +expect(mockFn).toHaveBeenCalledWith('arg'); + +expect(mockFn).toBeCalledWith('arg'); + +// Multiple assertions without call count check +expect(mockFn).toHaveBeenCalledWith('arg1'); +expect(mockFn).toHaveBeenCalledWith('arg2'); + +// Using toHaveBeenNthCalledWith without call count +expect(mockFn).toHaveBeenNthCalledWith(1, 'first'); + +// Using toHaveBeenLastCalledWith without call count +expect(mockFn).toHaveBeenLastCalledWith('last'); + +// Using toBeNthCalledWith without call count +expect(mockFn).toBeNthCalledWith(2, 'second'); + +// Using toBeLastCalledWith without call count +expect(mockFn).toBeLastCalledWith('last'); +``` + +### Correct + +```js +expect(mockFn).toHaveBeenCalledTimes(1); +expect(mockFn).toHaveBeenCalledWith('arg'); + +expect(mockFn).toBeCalledTimes(1); +expect(mockFn).toBeCalledWith('arg'); + +// Multiple mocks, each with call count +expect(mockFn1).toHaveBeenCalledTimes(1); +expect(mockFn1).toHaveBeenCalledWith('arg1'); +expect(mockFn2).toHaveBeenCalledTimes(1); +expect(mockFn2).toHaveBeenCalledWith('arg2'); + +// Using toHaveBeenNthCalledWith with call count +expect(mockFn).toHaveBeenCalledTimes(2); +expect(mockFn).toHaveBeenNthCalledWith(1, 'first'); + +// Using toHaveBeenLastCalledWith with call count +expect(mockFn).toHaveBeenCalledTimes(3); +expect(mockFn).toHaveBeenLastCalledWith('last'); + +// Using toBeNthCalledWith with call count +expect(mockFn).toBeCalledTimes(2); +expect(mockFn).toBeNthCalledWith(2, 'second'); + +// Using toBeLastCalledWith with call count +expect(mockFn).toBeCalledTimes(1); +expect(mockFn).toBeLastCalledWith('only'); + +// Mixed matchers with call count +expect(mockFn).toHaveBeenCalledTimes(3); +expect(mockFn).toHaveBeenCalledWith('arg1'); +expect(mockFn).toHaveBeenNthCalledWith(2, 'arg2'); +expect(mockFn).toHaveBeenLastCalledWith('arg3'); + +// Empty call (no arguments) doesn't require call count +expect(mockFn).toHaveBeenCalledWith(); + +// Using 'not' modifier doesn't require call count +expect(mockFn).not.toHaveBeenCalledWith('arg'); + +// Only checking call count is fine +expect(mockFn).toHaveBeenCalledTimes(1); +``` + +## Additional Checks + +### Contradictory Assertions + +This rule also detects contradictory assertions where `toHaveBeenCalledTimes(0)` +is used together with `toHaveBeenCalledWith()`. Since `toHaveBeenCalledWith()` +expects the mock to be called at least once, using it with +`toHaveBeenCalledTimes(0)` is contradictory. + +```js +// ❌ Incorrect - contradictory assertions +test('foo', () => { + expect(mockFn).toHaveBeenCalledTimes(0); + expect(mockFn).toHaveBeenCalledWith('arg'); // This expects a call! +}); + +// ✅ Correct - consistent assertions +test('foo', () => { + expect(mockFn).toHaveBeenCalledTimes(0); + // No CalledWith assertions +}); + +// ✅ Correct - consistent assertions +test('foo', () => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('arg'); +}); +``` + +## When Not To Use It + +If you have a specific testing strategy where checking call counts is not +necessary or you're only interested in verifying that a function was called with +specific arguments at least once, you can disable this rule. + +However, it's generally recommended to keep this rule enabled as it helps catch +bugs where functions are called more times than expected, leading to more robust +and reliable tests. diff --git a/src/__tests__/__snapshots__/rules.test.ts.snap b/src/__tests__/__snapshots__/rules.test.ts.snap index cbd7a4236..74a279181 100644 --- a/src/__tests__/__snapshots__/rules.test.ts.snap +++ b/src/__tests__/__snapshots__/rules.test.ts.snap @@ -45,6 +45,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/padding-around-describe-blocks": "error", "jest/padding-around-expect-groups": "error", "jest/padding-around-test-blocks": "error", + "jest/pair-to-have-been-called-assertions": "error", "jest/prefer-called-with": "error", "jest/prefer-comparison-matcher": "error", "jest/prefer-each": "error", @@ -137,6 +138,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/padding-around-describe-blocks": "error", "jest/padding-around-expect-groups": "error", "jest/padding-around-test-blocks": "error", + "jest/pair-to-have-been-called-assertions": "error", "jest/prefer-called-with": "error", "jest/prefer-comparison-matcher": "error", "jest/prefer-each": "error", diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 857f65a41..a1a9af556 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -2,7 +2,7 @@ import { existsSync } from 'fs'; import { resolve } from 'path'; import plugin from '../'; -const numberOfRules = 63; +const numberOfRules = 64; const ruleNames = Object.keys(plugin.rules); const deprecatedRules = Object.entries(plugin.rules) .filter(([, rule]) => rule.meta.deprecated) diff --git a/src/rules/__tests__/pair-to-have-been-called-assertions.test.ts b/src/rules/__tests__/pair-to-have-been-called-assertions.test.ts new file mode 100644 index 000000000..a4b29360b --- /dev/null +++ b/src/rules/__tests__/pair-to-have-been-called-assertions.test.ts @@ -0,0 +1,490 @@ +import rule from '../pair-to-have-been-called-assertions'; +import { FlatCompatRuleTester, espreeParser } from './test-utils'; + +const ruleTester = new FlatCompatRuleTester({ + parser: espreeParser, + parserOptions: { + ecmaVersion: 2020, + }, +}); + +ruleTester.run('pair-to-have-been-called-assertions', rule, { + valid: [ + // Has both toHaveBeenCalledWith and toHaveBeenCalledTimes + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("arg"); });', + // Has both toBeCalledWith and toBeCalledTimes + 'it("foo", function() { expect(mockFn).toBeCalledTimes(1); expect(mockFn).toBeCalledWith("arg"); });', + // Only toHaveBeenCalledTimes without toHaveBeenCalledWith + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1); });', + // toHaveBeenCalled without CalledWith - not detected (handled by prefer-called-with rule) + 'test("foo", function() { expect(mockFn).toHaveBeenCalled(); });', + // toBeCalled without CalledWith - not detected (handled by prefer-called-with rule) + 'test("foo", function() { expect(mockFn).toBeCalled(); });', + // toHaveBeenCalledWith with toHaveBeenCalledTimes (empty call) + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(); });', + // Multiple mocks, all properly paired + 'test("foo", function() { expect(mockFn1).toHaveBeenCalledTimes(1); expect(mockFn1).toHaveBeenCalledWith("arg1"); expect(mockFn2).toHaveBeenCalledTimes(1); expect(mockFn2).toHaveBeenCalledWith("arg2"); });', + // Using not modifier + 'test("foo", function() { expect(mockFn).not.toHaveBeenCalledWith("arg"); });', + // MemberExpression with both assertions + 'test("foo", function() { expect(props.onIssueChange).toHaveBeenCalledTimes(1); expect(props.onIssueChange).toHaveBeenCalledWith("arg"); });', + // Multiple tests in same file - each test is independent + 'test("test1", function() { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("arg1"); }); test("test2", function() { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("arg2"); });', + // Wrapped mocks with mocked() + 'test("foo", function() { expect(mocked(mockFn)).toHaveBeenCalledTimes(1); expect(mocked(mockFn)).toHaveBeenCalledWith("arg"); });', + // Wrapped with jest.mocked() + 'test("foo", function() { expect(jest.mocked(obj.method)).toHaveBeenCalledTimes(1); expect(jest.mocked(obj.method)).toHaveBeenCalledWith("arg"); });', + // toHaveBeenNthCalledWith with toHaveBeenCalledTimes + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(2); expect(mockFn).toHaveBeenNthCalledWith(1, "first"); });', + 'test("foo", function() { expect(mockFn).toBeCalledTimes(2); expect(mockFn).toBeNthCalledWith(2, "second"); });', + // toHaveBeenLastCalledWith with toHaveBeenCalledTimes + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(3); expect(mockFn).toHaveBeenLastCalledWith("last"); });', + 'test("foo", function() { expect(mockFn).toBeCalledTimes(1); expect(mockFn).toBeLastCalledWith("only"); });', + // Mixed matchers with toHaveBeenCalledTimes + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(3); expect(mockFn).toHaveBeenCalledWith("arg1"); expect(mockFn).toHaveBeenNthCalledWith(2, "arg2"); expect(mockFn).toHaveBeenLastCalledWith("arg3"); });', + // Computed properties with bracket notation + 'test("foo", function() { expect(obj["method"]).toHaveBeenCalledTimes(1); expect(obj["method"]).toHaveBeenCalledWith("arg"); });', + // Optional chaining + 'test("foo", function() { expect(obj?.method).toHaveBeenCalledTimes(1); expect(obj?.method).toHaveBeenCalledWith("arg"); });', + // Computed properties with template literals + 'test("foo", function() { const suffix = ""; expect(mock[`method${suffix}`]).toHaveBeenCalledTimes(1); expect(mock[`method${suffix}`]).toHaveBeenCalledWith("arg"); });', + // Spread element in expect (edge case - will be ignored) + 'test("foo", function() { expect(...args).toHaveBeenCalledTimes(1); expect(...args).toHaveBeenCalledWith("arg"); });', + ], + + invalid: [ + // Missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenCalledWith("arg"); });', + output: + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1);\n expect(mockFn).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Missing toBeCalledTimes + { + code: 'it("foo", function() { expect(mockFn).toBeCalledWith("arg"); });', + output: + 'it("foo", function() { expect(mockFn).toBeCalledTimes(1);\n expect(mockFn).toBeCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toBeCalledWith', + calledTimesName: 'toBeCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Multiple calls to toHaveBeenCalledWith without toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenCalledWith("arg1"); expect(mockFn).toHaveBeenCalledWith("arg2"); });', + output: + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(2);\n expect(mockFn).toHaveBeenCalledWith("arg1"); expect(mockFn).toHaveBeenCalledWith("arg2"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '2', + }, + }, + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '2', + }, + }, + ], + }, + // One mock properly paired, another missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mockFn1).toHaveBeenCalledTimes(1); expect(mockFn1).toHaveBeenCalledWith("arg1"); expect(mockFn2).toHaveBeenCalledWith("arg2"); });', + output: + 'test("foo", function() { expect(mockFn1).toHaveBeenCalledTimes(1); expect(mockFn1).toHaveBeenCalledWith("arg1"); expect(mockFn2).toHaveBeenCalledTimes(1);\n expect(mockFn2).toHaveBeenCalledWith("arg2"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // MemberExpression missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(props.onIssueChange).toHaveBeenCalledWith("arg"); });', + output: + 'test("foo", function() { expect(props.onIssueChange).toHaveBeenCalledTimes(1);\n expect(props.onIssueChange).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Multiple tests - one correct, one incorrect + { + code: 'test("test1", function() { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("arg1"); }); test("test2", function() { expect(mockFn).toHaveBeenCalledWith("arg2"); });', + output: + 'test("test1", function() { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("arg1"); }); test("test2", function() { expect(mockFn).toHaveBeenCalledTimes(1);\n expect(mockFn).toHaveBeenCalledWith("arg2"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Wrapped mock missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mocked(mockFn)).toHaveBeenCalledWith("arg"); });', + output: + 'test("foo", function() { expect(mocked(mockFn)).toHaveBeenCalledTimes(1);\n expect(mocked(mockFn)).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // toHaveBeenNthCalledWith missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenNthCalledWith(1, "arg"); });', + output: + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1);\n expect(mockFn).toHaveBeenNthCalledWith(1, "arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenNthCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // toHaveBeenNthCalledWith with variable index missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { const index = 1; expect(mockFn).toHaveBeenNthCalledWith(index, "arg"); });', + output: + 'test("foo", function() { const index = 1; expect(mockFn).toHaveBeenCalledTimes(1);\n expect(mockFn).toHaveBeenNthCalledWith(index, "arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenNthCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // toBeNthCalledWith missing toBeCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toBeNthCalledWith(2, "arg"); });', + output: + 'test("foo", function() { expect(mockFn).toBeCalledTimes(2);\n expect(mockFn).toBeNthCalledWith(2, "arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toBeNthCalledWith', + calledTimesName: 'toBeCalledTimes', + suggestedCount: '2', + }, + }, + ], + }, + // toHaveBeenLastCalledWith missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenLastCalledWith("arg"); });', + output: + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1);\n expect(mockFn).toHaveBeenLastCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenLastCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // toBeLastCalledWith missing toBeCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toBeLastCalledWith("arg"); });', + output: + 'test("foo", function() { expect(mockFn).toBeCalledTimes(1);\n expect(mockFn).toBeLastCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toBeLastCalledWith', + calledTimesName: 'toBeCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Mixed matchers - some with toHaveBeenCalledTimes, some without + { + code: 'test("foo", function() { expect(mockFn1).toHaveBeenCalledTimes(2); expect(mockFn1).toHaveBeenNthCalledWith(1, "arg1"); expect(mockFn2).toHaveBeenLastCalledWith("arg2"); });', + output: + 'test("foo", function() { expect(mockFn1).toHaveBeenCalledTimes(2); expect(mockFn1).toHaveBeenNthCalledWith(1, "arg1"); expect(mockFn2).toHaveBeenCalledTimes(1);\n expect(mockFn2).toHaveBeenLastCalledWith("arg2"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenLastCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Computed properties missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(obj["method"]).toHaveBeenCalledWith("arg"); });', + output: + 'test("foo", function() { expect(obj["method"]).toHaveBeenCalledTimes(1);\n expect(obj["method"]).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Mixed CalledWith and NthCalledWith - should suggest based on highest Nth + { + code: 'test("foo", function() { expect(props.onIssueChange).toHaveBeenCalledWith("none"); expect(props.onIssueChange).toHaveBeenNthCalledWith(1, "none"); expect(props.onIssueChange).toHaveBeenNthCalledWith(2, "lowVisibility"); });', + output: + 'test("foo", function() { expect(props.onIssueChange).toHaveBeenCalledTimes(2);\n expect(props.onIssueChange).toHaveBeenCalledWith("none"); expect(props.onIssueChange).toHaveBeenNthCalledWith(1, "none"); expect(props.onIssueChange).toHaveBeenNthCalledWith(2, "lowVisibility"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '2', + }, + }, + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenNthCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '2', + }, + }, + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenNthCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '2', + }, + }, + ], + }, + // Optional chaining missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(obj?.method).toHaveBeenCalledWith("arg"); });', + output: + 'test("foo", function() { expect(obj?.method).toHaveBeenCalledTimes(1);\n expect(obj?.method).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Template literals in computed properties missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { const suffix = ""; expect(mock[`method${suffix}`]).toHaveBeenCalledWith("arg"); });', + output: + 'test("foo", function() { const suffix = ""; expect(mock[`method${suffix}`]).toHaveBeenCalledTimes(1);\n expect(mock[`method${suffix}`]).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // Template literal with complex expression missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mock[`method${obj.prop}`]).toHaveBeenCalledWith("arg"); });', + output: + 'test("foo", function() { expect(mock[`method${obj.prop}`]).toHaveBeenCalledTimes(1);\n expect(mock[`method${obj.prop}`]).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // toHaveBeenCalledWith without arguments missing toHaveBeenCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenCalledWith(); });', + output: + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1);\n expect(mockFn).toHaveBeenCalledWith(); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + // toBeCalledWith without arguments missing toBeCalledTimes + { + code: 'test("foo", function() { expect(mockFn).toBeCalledWith(); });', + output: + 'test("foo", function() { expect(mockFn).toBeCalledTimes(1);\n expect(mockFn).toBeCalledWith(); });', + errors: [ + { + messageId: 'preferStrictMockAssertions', + data: { + matcherName: 'toBeCalledWith', + calledTimesName: 'toBeCalledTimes', + suggestedCount: '1', + }, + }, + ], + }, + ], +}); + +// Tests for contradictory toHaveBeenCalledTimes(0) +ruleTester.run( + 'pair-to-have-been-called-assertions (contradictory assertions)', + rule, + { + valid: [ + // toHaveBeenCalledTimes(0) without toHaveBeenCalledWith is fine + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(0); });', + 'test("foo", function() { expect(mockFn).toBeCalledTimes(0); });', + // toHaveBeenCalledTimes(1+) with toHaveBeenCalledWith is fine + 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("arg"); });', + ], + invalid: [ + // Contradictory: toHaveBeenCalledTimes(0) but toHaveBeenCalledWith expects a call + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(0); expect(mockFn).toHaveBeenCalledWith("arg"); });', + errors: [ + { + messageId: 'contradictoryCallTimes', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + }, + }, + ], + }, + // Contradictory: toBeCalledTimes(0) but toBeCalledWith expects a call + { + code: 'test("foo", function() { expect(mockFn).toBeCalledTimes(0); expect(mockFn).toBeCalledWith("arg"); });', + errors: [ + { + messageId: 'contradictoryCallTimes', + data: { + matcherName: 'toBeCalledWith', + calledTimesName: 'toBeCalledTimes', + }, + }, + ], + }, + // Contradictory: toHaveBeenCalledTimes(0) with toHaveBeenNthCalledWith + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(0); expect(mockFn).toHaveBeenNthCalledWith(1, "arg"); });', + errors: [ + { + messageId: 'contradictoryCallTimes', + data: { + matcherName: 'toHaveBeenNthCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + }, + }, + ], + }, + // Contradictory: toHaveBeenCalledTimes(0) with toHaveBeenLastCalledWith + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(0); expect(mockFn).toHaveBeenLastCalledWith("arg"); });', + errors: [ + { + messageId: 'contradictoryCallTimes', + data: { + matcherName: 'toHaveBeenLastCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + }, + }, + ], + }, + // Multiple contradictory assertions + { + code: 'test("foo", function() { expect(mockFn).toHaveBeenCalledTimes(0); expect(mockFn).toHaveBeenCalledWith("arg1"); expect(mockFn).toHaveBeenCalledWith("arg2"); });', + errors: [ + { + messageId: 'contradictoryCallTimes', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + }, + }, + { + messageId: 'contradictoryCallTimes', + data: { + matcherName: 'toHaveBeenCalledWith', + calledTimesName: 'toHaveBeenCalledTimes', + }, + }, + ], + }, + ], + }, +); diff --git a/src/rules/pair-to-have-been-called-assertions.ts b/src/rules/pair-to-have-been-called-assertions.ts new file mode 100644 index 000000000..ee562fac8 --- /dev/null +++ b/src/rules/pair-to-have-been-called-assertions.ts @@ -0,0 +1,396 @@ +import { + AST_NODE_TYPES, + type TSESLint, + type TSESTree, +} from '@typescript-eslint/utils'; +import { + createRule, + getAccessorValue, + getNodeName, + isTypeOfJestFnCall, + parseJestFnCall, +} from './utils'; + +interface MockCallInfo { + node: TSESTree.Node; + matcherName: string; + expectCall: TSESTree.CallExpression; + nthIndex?: number; +} + +interface CalledTimesZeroInfo { + node: TSESTree.Node; + calledTimesName: string; +} + +interface TestContext { + expectCallsWithCalledWith: Map; + expectCallsWithCalledTimes: Set; + expectCallsWithCalledTimesZero: Map; +} + +const createTestContext = (): TestContext => ({ + expectCallsWithCalledWith: new Map(), + expectCallsWithCalledTimes: new Set(), + expectCallsWithCalledTimesZero: new Map(), +}); + +const CALLED_WITH_MATCHERS = new Set([ + 'toHaveBeenCalledWith', + 'toBeCalledWith', + 'toHaveBeenNthCalledWith', + 'toBeNthCalledWith', + 'toHaveBeenLastCalledWith', + 'toBeLastCalledWith', +]); + +const CALLED_TIMES_MATCHERS = new Set([ + 'toHaveBeenCalledTimes', + 'toBeCalledTimes', +]); + +const NTH_CALLED_WITH_MATCHERS = new Set([ + 'toHaveBeenNthCalledWith', + 'toBeNthCalledWith', +]); + +/** + * Generate a string representation of a template literal node + */ +function getTemplateLiteralString(node: TSESTree.TemplateLiteral) { + const parts = node.quasis.map((quasi, i) => { + const expr = node.expressions[i]; + const exprText = + expr?.type === AST_NODE_TYPES.Identifier + ? `\${${expr.name}}` + : expr + ? '${expr}' + : ''; + + return quasi.value.raw + exprText; + }); + + return `template:${parts.join('')}`; +} + +/** + * Get a string representation of the expression for tracking purposes + * Extends getNodeName() to handle: + * - ChainExpression (optional chaining): obj?.method + * - Template literals with expressions: obj[`method${suffix}`] + */ +function getMockIdentifier( + node: TSESTree.Expression | TSESTree.SpreadElement, +): string | null { + if (node.type === AST_NODE_TYPES.SpreadElement) { + return null; + } + + // Handle optional chaining: obj?.method + if (node.type === AST_NODE_TYPES.ChainExpression) { + return getMockIdentifier(node.expression); + } + + // Handle template literals with expressions in member expressions + if ( + node.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.TemplateLiteral + ) { + const object = getMockIdentifier(node.object); + const property = getTemplateLiteralString(node.property); + + return object ? `${object}.${property}` : /* istanbul ignore next */ null; + } + + // Use built-in utility for all other cases + return getNodeName(node); +} + +/** + * Determine the correct calledTimes matcher name based on the calledWith matcher + */ +function getCalledTimesName(matcherName: string) { + return matcherName.startsWith('toHaveBeen') + ? 'toHaveBeenCalledTimes' + : 'toBeCalledTimes'; +} + +/** + * Extract the nth index from NthCalledWith matcher arguments + */ +function extractNthIndex( + matcherName: string, + args: TSESTree.CallExpressionArgument[], +) { + if (!NTH_CALLED_WITH_MATCHERS.has(matcherName)) { + return undefined; + } + + const [firstArg] = args; + + return firstArg.type === AST_NODE_TYPES.Literal && + typeof firstArg.value === 'number' + ? firstArg.value + : undefined; +} + +/** + * Calculate suggested call count based on the assertions + */ +function getSuggestedCount(calledWithCalls: MockCallInfo[]) { + const maxNthIndex = Math.max( + 0, + ...calledWithCalls + .map(({ nthIndex }) => nthIndex) + .filter((index): index is number => index !== undefined), + ); + + return maxNthIndex || calledWithCalls.length; +} + +/** + * Find the statement node containing the given node + */ +function findContainingStatement(node: TSESTree.Node) { + let current: TSESTree.Node | undefined = node; + + while (current) { + if (current.type === AST_NODE_TYPES.ExpressionStatement) { + return current; + } + current = current.parent; + } + + /* istanbul ignore next */ + return undefined; +} + +/** + * Report contradictory toHaveBeenCalledTimes(0) assertions + */ +function reportContradictoryAssertions( + context: TSESLint.RuleContext, + calledWithCalls: MockCallInfo[], + calledTimesZero: CalledTimesZeroInfo, +) { + for (const { node: matcherNode, matcherName } of calledWithCalls) { + context.report({ + node: matcherNode, + messageId: 'contradictoryCallTimes', + data: { + matcherName, + calledTimesName: calledTimesZero.calledTimesName, + }, + }); + } +} + +/** + * Report missing toHaveBeenCalledTimes assertions + */ +function reportMissingCalledTimes( + context: TSESLint.RuleContext, + sourceCode: TSESLint.SourceCode, + calledWithCalls: MockCallInfo[], +) { + const suggestedCount = getSuggestedCount(calledWithCalls); + const [firstCall] = calledWithCalls; + const calledTimesNameForFix = getCalledTimesName(firstCall.matcherName); + const fixer = createCalledTimesFixer( + sourceCode, + firstCall.expectCall, + calledTimesNameForFix, + suggestedCount, + ); + + for (let index = 0; index < calledWithCalls.length; index++) { + const { node: matcherNode, matcherName } = calledWithCalls[index]; + const calledTimesName = getCalledTimesName(matcherName); + + context.report({ + node: matcherNode, + messageId: 'preferStrictMockAssertions', + data: { + matcherName, + calledTimesName, + suggestedCount: String(suggestedCount), + }, + fix: index === 0 ? fixer : undefined, + }); + } +} + +/** + * Create a fixer that inserts toHaveBeenCalledTimes before the expect call + */ +function createCalledTimesFixer( + sourceCode: TSESLint.SourceCode, + expectCall: TSESTree.CallExpression, + calledTimesName: string, + suggestedCount: number, +) { + return (fixer: TSESLint.RuleFixer) => { + const [mockArg] = expectCall.arguments; + + /* istanbul ignore if */ + if (!mockArg) { + return null; + } + + const statement = findContainingStatement(expectCall); + + /* istanbul ignore if */ + if (!statement) { + return null; + } + + // Get the mock expression and create the fix + const indent = ' '.repeat(statement.loc.start.column); + const mockText = sourceCode.getText(mockArg); + const fixText = `expect(${mockText}).${calledTimesName}(${suggestedCount});\n${indent}`; + + return fixer.insertTextBefore(statement, fixText); + }; +} + +export default createRule({ + name: __filename, + meta: { + docs: { + description: + 'Require `toHaveBeenCalledTimes()` when using `toHaveBeenCalledWith()`', + }, + messages: { + preferStrictMockAssertions: + 'Prefer using {{ calledTimesName }}({{ suggestedCount }}) with {{ matcherName }}() to ensure exact call count', + contradictoryCallTimes: + 'Contradictory assertion: {{ calledTimesName }}(0) is used but {{ matcherName }}() expects the mock to be called', + }, + type: 'suggestion', + schema: [], + fixable: 'code', + }, + defaultOptions: [], + create(context) { + const { sourceCode } = context; + + // Stack to track nested test functions + const testStack: TestContext[] = []; + + let currentTestContext: TestContext | null = null; + + return { + CallExpression(node) { + const jestFnCall = parseJestFnCall(node, context); + + // Check if this is a test function call (it/test) + if (jestFnCall?.type === 'test') { + // Start tracking for this test + currentTestContext = createTestContext(); + + testStack.push(currentTestContext); + } + + // Only track expect calls if we're inside a test + if (jestFnCall?.type !== 'expect' || !currentTestContext) { + return; + } + + // Ignore expect calls with 'not' modifier + if (jestFnCall.modifiers.some(mod => getAccessorValue(mod) === 'not')) { + return; + } + + const { matcher, args, head } = jestFnCall; + const matcherName = getAccessorValue(matcher); + + // Get the expect() call expression + const { parent: expectCall } = head.node; + + /* istanbul ignore if */ + if (expectCall?.type !== AST_NODE_TYPES.CallExpression) { + return; + } + + // Get the mock function argument from expect(mockFn) + const [expectArg] = expectCall.arguments; + const mockName = getMockIdentifier(expectArg); + + /* istanbul ignore if */ + if (!mockName) { + return; + } + + // Track expect calls with *CalledWith matchers + if (CALLED_WITH_MATCHERS.has(matcherName)) { + if (!currentTestContext.expectCallsWithCalledWith.has(mockName)) { + currentTestContext.expectCallsWithCalledWith.set(mockName, []); + } + + currentTestContext.expectCallsWithCalledWith.get(mockName)!.push({ + node: matcher, + matcherName, + expectCall, + nthIndex: extractNthIndex(matcherName, args), + }); + } + + // Track expect calls with *CalledTimes matchers + if (CALLED_TIMES_MATCHERS.has(matcherName)) { + currentTestContext.expectCallsWithCalledTimes.add(mockName); + + // Check if the argument is 0, which would be contradictory with toHaveBeenCalledWith + const [firstArg] = args; + + if ( + firstArg?.type === AST_NODE_TYPES.Literal && + firstArg.value === 0 + ) { + currentTestContext.expectCallsWithCalledTimesZero.set(mockName, { + node: matcher, + calledTimesName: matcherName, + }); + } + } + }, + 'CallExpression:exit'(node) { + // Check if we're exiting a test function + if (!isTypeOfJestFnCall(node, context, ['test'])) { + return; + } + + // Pop the test context and check for violations + const testContext = testStack.pop(); + + /* istanbul ignore if */ + if (!testContext) { + return; + } + + // Check for violations in expect calls + for (const [ + mockName, + calledWithCalls, + ] of testContext.expectCallsWithCalledWith) { + const calledTimesZero = + testContext.expectCallsWithCalledTimesZero.get(mockName); + + // Check for contradictory toHaveBeenCalledTimes(0) with toHaveBeenCalledWith + if (calledTimesZero) { + reportContradictoryAssertions( + context, + calledWithCalls, + calledTimesZero, + ); + } else if (!testContext.expectCallsWithCalledTimes.has(mockName)) { + // Check for toHaveBeenCalledWith/toBeCalledWith without corresponding toHaveBeenCalledTimes/toBeCalledTimes + reportMissingCalledTimes(context, sourceCode, calledWithCalls); + } + } + + // Update currentTestContext to parent or null + currentTestContext = testStack.at(-1) ?? null; + }, + }; + }, +});