diff --git a/crates/oxc_linter/src/generated/rule_runner_impls.rs b/crates/oxc_linter/src/generated/rule_runner_impls.rs index 32d2155e78256..3be5d2476ade7 100644 --- a/crates/oxc_linter/src/generated/rule_runner_impls.rs +++ b/crates/oxc_linter/src/generated/rule_runner_impls.rs @@ -1481,6 +1481,11 @@ impl RuleRunner for crate::rules::jest::prefer_to_contain::PreferToContain { const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; } +impl RuleRunner for crate::rules::jest::prefer_to_have_been_called::PreferToHaveBeenCalled { + const NODE_TYPES: Option<&AstTypesBitset> = None; + const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; +} + impl RuleRunner for crate::rules::jest::prefer_to_have_length::PreferToHaveLength { const NODE_TYPES: Option<&AstTypesBitset> = None; const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index dd8224ff03c89..198b1424ba120 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -338,6 +338,7 @@ pub(crate) mod jest { pub mod prefer_strict_equal; pub mod prefer_to_be; pub mod prefer_to_contain; + pub mod prefer_to_have_been_called; pub mod prefer_to_have_length; pub mod prefer_todo; pub mod require_hook; @@ -917,6 +918,7 @@ oxc_macros::declare_all_lint_rules! { jest::prefer_strict_equal, jest::prefer_to_be, jest::prefer_to_contain, + jest::prefer_to_have_been_called, jest::prefer_to_have_length, jest::prefer_todo, jest::require_hook, diff --git a/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called.rs b/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called.rs new file mode 100644 index 0000000000000..c358a86966949 --- /dev/null +++ b/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called.rs @@ -0,0 +1,202 @@ +use crate::{ + context::LintContext, + rule::Rule, + utils::{ParsedExpectFnCall, PossibleJestNode, parse_expect_jest_fn_call}, +}; +use oxc_ast::{ + AstKind, + ast::{CallExpression, Expression}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +fn prefer_to_have_been_called_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)`") + .with_help("Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called") + .with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct PreferToHaveBeenCalled; + +// See for documentation details. +declare_oxc_lint!( + /// ### What it does + /// + /// Suggests using `toHaveBeenCalled()` or `not.toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` or `toBeCalledTimes(0)`. + /// + /// ### Why is this bad? + /// + /// `toHaveBeenCalled()` is more explicit and readable than `toHaveBeenCalledTimes(0)`. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// expect(mock).toHaveBeenCalledTimes(0); + /// expect(mock).toBeCalledTimes(0); + /// expect(mock).not.toHaveBeenCalledTimes(0); + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```js + /// expect(mock).not.toHaveBeenCalled(); + /// expect(mock).toHaveBeenCalled(); + /// expect(mock).toHaveBeenCalledTimes(1); + /// ``` + PreferToHaveBeenCalled, + jest, + style, + fix, +); + +impl Rule for PreferToHaveBeenCalled { + fn run_on_jest_node<'a, 'c>( + &self, + jest_node: &PossibleJestNode<'a, 'c>, + ctx: &'c LintContext<'a>, + ) { + Self::run(jest_node, ctx); + } +} +impl PreferToHaveBeenCalled { + fn run<'a, 'c>(jest_node: &PossibleJestNode<'a, 'c>, ctx: &'c LintContext<'a>) { + let node = jest_node.node; + + let AstKind::CallExpression(call_expr) = node.kind() else { + return; + }; + + let Some(parsed_expect_call) = parse_expect_jest_fn_call(call_expr, jest_node, ctx) else { + return; + }; + + Self::check_and_fix(&parsed_expect_call, call_expr, ctx); + } + + fn check_and_fix<'a>( + parsed_expect_call: &ParsedExpectFnCall<'a>, + call_expr: &CallExpression<'a>, + ctx: &LintContext<'a>, + ) { + let Some(matcher) = parsed_expect_call.matcher() else { + return; + }; + + if matcher.is_name_unequal("toHaveBeenCalledTimes") + && matcher.is_name_unequal("toBeCalledTimes") + { + return; + } + + // check if first argument is 0 + let Some(arg) = parsed_expect_call.args.first() else { return }; + let Some(arg_expr) = arg.as_expression() else { + return; + }; + if !is_zero_arg(arg_expr) { + return; + } + + ctx.diagnostic_with_fix(prefer_to_have_been_called_diagnostic(call_expr.span), |fixer| { + // check if there is a `not` modifier + let binding = parsed_expect_call.modifiers(); + let not_modifier = binding.iter().find(|modifier| modifier.is_name_equal("not")); + + if let Some(not_modifier) = not_modifier { + // if has `not` modifier, remove not and replace with toHaveBeenCalled() + // need to find the position of not and replace to the end of the method call + let not_start = not_modifier.span.start; + + let call_end = call_expr.span.end; + let replace_span = Span::new(not_start, call_end); + + fixer.replace(replace_span, "toHaveBeenCalled()") + } else { + // if does not have `not` modifier, add `not.` before method name + let method_start = matcher.span.start; + let call_end = call_expr.span.end; + let replace_span = Span::new(method_start, call_end); + + fixer.replace(replace_span, "not.toHaveBeenCalled()") + } + }); + } +} + +fn is_zero_arg(expr: &Expression<'_>) -> bool { + match expr.get_inner_expression() { + Expression::NumericLiteral(lit) => lit.value == 0.0, + Expression::BigIntLiteral(lit) => lit.value == "0", + _ => false, + } +} +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "expect(method.mock.calls).toHaveLength;", + "expect(method.mock.calls).toHaveLength(0);", + "expect(method).toHaveBeenCalledTimes(1)", + "expect(method).not.toHaveBeenCalledTimes(x)", + "expect(method).not.toHaveBeenCalledTimes(1)", + "expect(method).not.toHaveBeenCalledTimes(...x)", + "expect(a);", + "expect(method).not.resolves.toHaveBeenCalledTimes(0);", + "expect(method).toBe([])", + "expect(fn.mock.calls).toEqual([])", + "expect(fn.mock.calls).toContain(1, 2, 3)", + ]; + + let fail: Vec<&str> = vec![ + "expect(method).toBeCalledTimes(0);", + "expect(method).not.toBeCalledTimes(0);", + "expect(method).toHaveBeenCalledTimes(0);", + "expect(method).not.toHaveBeenCalledTimes(0);", + "expect(method).not.toHaveBeenCalledTimes(0, 1, 2);", + "expect(method).resolves.toHaveBeenCalledTimes(0);", + "expect(method).rejects.not.toHaveBeenCalledTimes(0);", + "expect(method).toBeCalledTimes(0 as number);", + ]; + + let fix = vec![ + ("expect(method).toBeCalledTimes(0);", "expect(method).not.toHaveBeenCalled();", None), + ("expect(method).not.toBeCalledTimes(0);", "expect(method).toHaveBeenCalled();", None), + ( + "expect(method).toHaveBeenCalledTimes(0);", + "expect(method).not.toHaveBeenCalled();", + None, + ), + ( + "expect(method).not.toHaveBeenCalledTimes(0);", + "expect(method).toHaveBeenCalled();", + None, + ), + ( + "expect(method).not.toHaveBeenCalledTimes(0, 1, 2);", + "expect(method).toHaveBeenCalled();", + None, + ), + ( + "expect(method).resolves.toHaveBeenCalledTimes(0);", + "expect(method).resolves.not.toHaveBeenCalled();", + None, + ), + ( + "expect(method).rejects.not.toHaveBeenCalledTimes(0);", + "expect(method).rejects.toHaveBeenCalled();", + None, + ), + ( + "expect(method).toBeCalledTimes(0 as number);", + "expect(method).not.toHaveBeenCalled();", + None, + ), + ]; + Tester::new(PreferToHaveBeenCalled::NAME, PreferToHaveBeenCalled::PLUGIN, pass, fail) + .with_jest_plugin(true) + .expect_fix(fix) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/jest_prefer_to_have_been_called.snap b/crates/oxc_linter/src/snapshots/jest_prefer_to_have_been_called.snap new file mode 100644 index 0000000000000..b258fe420fba0 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/jest_prefer_to_have_been_called.snap @@ -0,0 +1,59 @@ +--- +source: crates/oxc_linter/src/tester.rs +assertion_line: 394 +--- + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).toBeCalledTimes(0); + · ───────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called + + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).not.toBeCalledTimes(0); + · ───────────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called + + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).toHaveBeenCalledTimes(0); + · ─────────────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called + + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).not.toHaveBeenCalledTimes(0); + · ─────────────────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called + + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).not.toHaveBeenCalledTimes(0, 1, 2); + · ───────────────────────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called + + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).resolves.toHaveBeenCalledTimes(0); + · ──────────────────────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called + + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).rejects.not.toHaveBeenCalledTimes(0); + · ─────────────────────────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called + + ⚠ eslint-plugin-jest(prefer-to-have-been-called): Prefer `toHaveBeenCalled()` over `toHaveBeenCalledTimes(0)` + ╭─[prefer_to_have_been_called.tsx:1:1] + 1 │ expect(method).toBeCalledTimes(0 as number); + · ─────────────────────────────────────────── + ╰──── + help: Use `toHaveBeenCalled()` to check if function was called, or `not.toHaveBeenCalled()` to check if it wasn't called