Skip to content

feat(rstest): add prefer-to-have-been-called-times rule - #2230

Merged
fansenze merged 5 commits into
mainfrom
feat/rstest-prefer-to-have-been-called-times
Sep 21, 2026
Merged

fansenze merged 5 commits into
mainfrom
feat/rstest-prefer-to-have-been-called-times

Conversation

@elecmonkey

@elecmonkey elecmonkey commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Summary

Add rstest/prefer-to-have-been-called-times to report toHaveLength() assertions on a mock's mock.calls array and rewrite them to toHaveBeenCalledTimes(). It is registered in the Rstest plugin without being added to the recommended preset, matching upstream.

The table of Chai matchers that replace the assertion subject and the predicate for a numeric literal written in source move to internal/utils/test_framework, behind IsSubjectMutatingChaiMatcher and StaticNumericLiteral, shared with rstest/prefer-to-have-length so the two rules cannot drift apart.

Credits

Port from eslint-plugin-jest's prefer-to-have-been-called-times.

Intentional differences from upstream

  • Identifier bracket keys are not treated as member names. expect(handler[mock].calls) reads a variable rather than the mock property, so reporting and rewriting it would be a false positive.
  • Dynamic matcher names are not reported. expect(handler.mock.calls)[matcher](2) names its matcher through a variable, which the assertion parser does not resolve to toHaveLength.
  • Parenthesized links inside the subject are rewritten correctly. Upstream deletes one accessor chain as a single span and turns expect((handler.mock).calls).toHaveLength(2) into unparseable code.
  • Optional chaining on the matcher is preserved. Upstream rewrites the whole matcher member, which drops the ?. in expect(handler.mock.calls)?.toHaveLength(2).
  • Computed accessor delimiters are preserved. Upstream rewrites expect(handler.mock.calls)["toHaveLength"](2) to a dotted accessor; this port changes only the literal value.
  • expect.soft assertions are checked. Soft assertions record the same call count, so they are treated like ordinary assertions.
  • Matchers after a subject-changing Chai matcher are not reported. expect(handler.mock.calls).property(0).toHaveLength(2) measures the arguments of one recorded call rather than the call count, so the chain ends at property.
  • expect.poll assertions are not checked. expect.poll takes a callback, so the only shape this rule could match there is an argument that throws before any matcher runs.
  • Only a count written as a number is fixed. toHaveLength compares loosely and toHaveBeenCalledTimes strictly, so rewriting toHaveLength('1') fails an assertion that passed and rewriting not.toHaveLength('1') passes one that failed.
  • Arguments that can reset the mock are reported without a fix. expect() captures the calls array while toHaveBeenCalledTimes reads mock.calls when the matcher runs, so expect(handler.mock.calls, (handler.mockClear(), 'called once')) would change result after the rewrite.
  • A super receiver is reported without a fix. A bare super is not a value, so expect(super.mock.calls) cannot become expect(super) without producing code that does not parse.
  • Assertions whose result is reused are reported without a fix. Rstest matchers return a Chai assertion that carries the rewritten subject into every later matcher, so only standalone assertion statements are fixed.

Known gaps

  • expect.extend overrides are not consulted. A file-local override of toHaveLength or toHaveBeenCalledTimes changes what either matcher means, and no matcher-rewrite rule in the plugin checks IsExpectMatcherOverridden today; making this one the exception would be inconsistent, so it is left for a separate change covering the whole family.

Related Links

Related #935

Checklist

  • Tests updated (or not required).
  • Documentation updated (or not required).

@elecmonkey
elecmonkey force-pushed the feat/rstest-prefer-to-have-been-called-times branch from d834e4e to 9f183d3 Compare September 20, 2026 18:27
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

🦀📦 Binary size

Commit 92f336f merged into base fb8d942 — feat(node): add no-unsupported-features/es-builtins (#2235).

Binary Base This PR Change
rslint (linux-x64-gnu) 39.79 MiB 39.80 MiB +12.00 KiB (+0.03%)

Stripped go build -ldflags="-s -w" ./cmd/rslint, Go 1.26.0, linux/amd64 · run · 2026-09-21 09:50 UTC

@elecmonkey elecmonkey left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found four correctness issues in the Rstest-specific behavior and autofix boundaries. Inline comments include minimal reproductions and suggested directions.

Stop the matcher walk at Chai matchers that replace the assertion subject,
so a toHaveLength after property(0) is no longer reported: it measures the
arguments of one recorded call rather than the call count.

Restrict the rule to expect() and expect.soft(). expect.poll() takes a
callback, so the only shape this rule could match there is an argument that
throws before any matcher runs.

Remove explicit type arguments on the expect() and matcher calls when
rewriting, since both describe the subject and matcher being replaced, and
withhold the fix when a comment sits inside either list.
Both rstest/prefer-to-have-length and rstest/prefer-to-have-been-called-times
stop their matcher walk at the Chai matchers that replace the assertion
object's value. Move the table behind IsSubjectMutatingChaiMatcher so the two
rules cannot drift apart, and cover it with a unit test.
@elecmonkey
elecmonkey marked this pull request as ready for review September 21, 2026 04:20

@fansenze fansenze left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three remaining autofix issues, reproduced on this revision with Rstest 0.11.11. The existing targeted Go tests and rule registration check pass; additional regression probes confirm the issues below.

…nge results

toHaveLength compares loosely and toHaveBeenCalledTimes strictly, so rewriting
toHaveLength('1') makes a passing assertion fail and rewriting
not.toHaveLength('1') makes a failing one pass. Offer the fix only when the
expected count is a number written in source.

expect() captures the calls array, while toHaveBeenCalledTimes reads mock.calls
when the matcher runs. Anything evaluated in between can reset the mock, so
require the remaining expect() arguments to be literals as well.

A bare super is not a value, so expect(super.mock.calls) cannot be rewritten to
expect(super) without producing code that does not parse.

All three cases keep their diagnostic and lose only the autofix. Move the
numeric-literal predicate shared with rstest/prefer-to-have-length into
test_framework as StaticNumericLiteral.
@fansenze
fansenze merged commit ded337e into main Sep 21, 2026
28 of 30 checks passed
@fansenze
fansenze deleted the feat/rstest-prefer-to-have-been-called-times branch September 21, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants