From 676de1d77ba19430d96f5df93ce8f3a548c6acfe Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sat, 29 Feb 2020 20:56:17 +1300 Subject: [PATCH] feat: create `prefer-to-be-object` rule --- README.md | 2 + docs/rules/prefer-to-be-object.md | 44 +++++++ .../__snapshots__/rules.test.ts.snap | 1 + src/__tests__/rules.test.ts | 2 +- .../__tests__/prefer-to-be-object.test.ts | 99 +++++++++++++++ src/rules/prefer-to-be-object.ts | 113 ++++++++++++++++++ 6 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 docs/rules/prefer-to-be-object.md create mode 100644 src/rules/__tests__/prefer-to-be-object.test.ts create mode 100644 src/rules/prefer-to-be-object.ts diff --git a/README.md b/README.md index a960b1e..a40bb72 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ for installations requiring long-term consistency. | ----------------------- | ---------------------------- | ------------------ | | [prefer-to-be-array][] | Suggest using `toBeArray()` | ![fixable-green][] | | [prefer-to-be-false][] | Suggest using `toBeFalse()` | ![fixable-green][] | +| [prefer-to-be-object][] | Suggest using `toBeObject()` | ![fixable-green][] | | [prefer-to-be-true][] | Suggest using `toBeTrue()` | ![fixable-green][] | ## Credit @@ -94,6 +95,7 @@ https://github.com/dangreenisrael/eslint-plugin-jest-formatting [prefer-to-be-array]: docs/rules/prefer-to-be-array.md [prefer-to-be-false]: docs/rules/prefer-to-be-false.md +[prefer-to-be-object]: docs/rules/prefer-to-be-object.md [prefer-to-be-true]: docs/rules/prefer-to-be-true.md [fixable-green]: https://img.shields.io/badge/-fixable-green.svg [fixable-yellow]: https://img.shields.io/badge/-fixable-yellow.svg diff --git a/docs/rules/prefer-to-be-object.md b/docs/rules/prefer-to-be-object.md new file mode 100644 index 0000000..03bba12 --- /dev/null +++ b/docs/rules/prefer-to-be-object.md @@ -0,0 +1,44 @@ +# Suggest using `toBeObject()` (prefer-to-be-object) + +For expecting a value to be an object, `jest-extended` provides the `toBeObject` +matcher. + +## Rule details + +This rule triggers a warning if an `expect` assertion is found asserting that a +value is an object using one of the following methods: + +- Comparing the result of ` instanceof Object` to a boolean value, +- Calling the `toBeInstanceOf` matcher with the `Object` class. + +The following patterns are considered warnings: + +```js +expect([] instanceof Object).toBe(true); + +expect(myValue instanceof Object).toStrictEqual(false); + +expect(theResults() instanceof Object).not.toBeFalse(); + +expect([]).toBeInstanceOf(true); + +expect(myValue).resolves.toBeInstanceOf(Object); + +expect(theResults()).not.toBeInstanceOf(Object); +``` + +The following patterns are _not_ considered warnings: + +```js +expect({}).toBeObject(); + +expect(myValue).not.toBeObject(); + +expect(queryApi()).resolves.toBeObject(); + +expect(theResults()).toBeObject(); +``` + +## Further Reading + +- [`jest-extended#toBeObject` matcher](https://github.com/jest-community/jest-extended#tobeobject) diff --git a/src/__tests__/__snapshots__/rules.test.ts.snap b/src/__tests__/__snapshots__/rules.test.ts.snap index 1fd5803..6bd0312 100644 --- a/src/__tests__/__snapshots__/rules.test.ts.snap +++ b/src/__tests__/__snapshots__/rules.test.ts.snap @@ -9,6 +9,7 @@ Object { "rules": Object { "jest/prefer-to-be-array": "error", "jest/prefer-to-be-false": "error", + "jest/prefer-to-be-object": "error", "jest/prefer-to-be-true": "error", }, }, diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index ea2b685..44648fe 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'path'; import plugin from '../'; const ruleNames = Object.keys(plugin.rules); -const numberOfRules = 3; +const numberOfRules = 4; describe('rules', () => { it('should have a corresponding doc for each rule', () => { diff --git a/src/rules/__tests__/prefer-to-be-object.test.ts b/src/rules/__tests__/prefer-to-be-object.test.ts new file mode 100644 index 0000000..afc24d5 --- /dev/null +++ b/src/rules/__tests__/prefer-to-be-object.test.ts @@ -0,0 +1,99 @@ +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import rule, { MessageIds, Options } from '../prefer-to-be-object'; + +const ruleTester = new TSESLint.RuleTester(); + +// makes ts happy about the dynamic test generation +const messageId = 'preferToBeObject' as const; + +const createTestsForEqualityMatchers = (): Array> => + ['toBe', 'toEqual', 'toStrictEqual'] + .map(matcher => [ + { + code: `expect({} instanceof Object).${matcher}(true);`, + errors: [{ messageId, column: 30, line: 1 }], + output: `expect({}).toBeObject();`, + }, + { + code: `expect({} instanceof Object).not.${matcher}(true);`, + errors: [{ messageId, column: 34, line: 1 }], + output: `expect({}).not.toBeObject();`, + }, + { + code: `expect({} instanceof Object).${matcher}(false);`, + errors: [{ messageId, column: 30, line: 1 }], + output: `expect({}).not.toBeObject();`, + }, + { + code: `expect({} instanceof Object).not.${matcher}(false);`, + errors: [{ messageId, column: 34, line: 1 }], + output: `expect({}).toBeObject();`, + }, + ]) + .reduce((arr, cur) => arr.concat(cur), []); + +ruleTester.run('prefer-to-be-object', rule, { + valid: [ + 'expect.hasAssertions', + 'expect', + 'expect().not', + 'expect().toBe', + 'expect().toBe(true)', + 'expect({}).toBe(true)', + 'expect({}).toBeObject()', + 'expect({}).not.toBeObject()', + 'expect([] instanceof Array).not.toBeObject()', + 'expect({}).not.toBeInstanceOf(Array)', + ], + invalid: [ + ...createTestsForEqualityMatchers(), + { + code: 'expect(({} instanceof Object)).toBeTrue();', + errors: [{ messageId, column: 32, line: 1 }], + output: 'expect(({})).toBeObject();', + }, + { + code: 'expect({} instanceof Object).toBeTrue();', + errors: [{ messageId, column: 30, line: 1 }], + output: 'expect({}).toBeObject();', + }, + { + code: 'expect({} instanceof Object).not.toBeTrue();', + errors: [{ messageId, column: 34, line: 1 }], + output: 'expect({}).not.toBeObject();', + }, + { + code: 'expect({} instanceof Object).toBeFalse();', + errors: [{ messageId, column: 30, line: 1 }], + output: 'expect({}).not.toBeObject();', + }, + { + code: 'expect({} instanceof Object).not.toBeFalse();', + errors: [{ messageId, column: 34, line: 1 }], + output: 'expect({}).toBeObject();', + }, + { + code: 'expect({}).toBeInstanceOf(Object);', + errors: [{ messageId, column: 12, line: 1 }], + output: 'expect({}).toBeObject();', + }, + { + code: 'expect({}).not.toBeInstanceOf(Object);', + errors: [{ messageId, column: 16, line: 1 }], + output: 'expect({}).not.toBeObject();', + }, + { + code: 'expect(requestValues()).resolves.toBeInstanceOf(Object);', + errors: [{ messageId, column: 34, line: 1 }], + output: 'expect(requestValues()).resolves.toBeObject();', + }, + { + code: 'expect(queryApi()).resolves.not.toBeInstanceOf(Object);', + errors: [{ messageId, column: 33, line: 1 }], + output: 'expect(queryApi()).resolves.not.toBeObject();', + }, + ], +}); diff --git a/src/rules/prefer-to-be-object.ts b/src/rules/prefer-to-be-object.ts new file mode 100644 index 0000000..cf37eb1 --- /dev/null +++ b/src/rules/prefer-to-be-object.ts @@ -0,0 +1,113 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; +import { + ModifierName, + createRule, + followTypeAssertionChain, + isBooleanEqualityMatcher, + isExpectCall, + isInstanceOfBinaryExpression, + isParsedInstanceOfMatcherCall, + parseExpectCall, +} from './utils'; + +export type MessageIds = 'preferToBeObject'; +export type Options = []; + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Stylistic Issues', + description: 'Suggest using `toBeObject()`', + recommended: false, + }, + messages: { + preferToBeObject: + 'Prefer using `toBeObject()` to test if a value is an Object.', + }, + fixable: 'code', + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + if (!isExpectCall(node)) { + return; + } + + const { expect, modifier, matcher } = parseExpectCall(node); + + if (!matcher) { + return; + } + + if (isParsedInstanceOfMatcherCall(matcher, 'Object')) { + context.report({ + node: matcher.node.property, + messageId: 'preferToBeObject', + fix: fixer => [ + fixer.replaceTextRange( + [ + matcher.node.property.range[0], + matcher.node.property.range[1] + '(Object)'.length, + ], + 'toBeObject()', + ), + ], + }); + + return; + } + + const [expectArg] = expect.arguments; + + if ( + !expectArg || + !isBooleanEqualityMatcher(matcher) || + !isInstanceOfBinaryExpression(expectArg, 'Object') + ) { + return; + } + + context.report({ + node: matcher.node.property, + messageId: 'preferToBeObject', + fix(fixer) { + const fixes = [ + fixer.replaceText(matcher.node.property, 'toBeObject'), + fixer.removeRange([expectArg.left.range[1], expectArg.range[1]]), + ]; + + let invertCondition = matcher.name === 'toBeFalse'; + + if (matcher.arguments?.length) { + const [matcherArg] = matcher.arguments; + + fixes.push(fixer.remove(matcherArg)); + + // toBeFalse can't have arguments, so this won't be true beforehand + invertCondition = + matcherArg.type === AST_NODE_TYPES.Literal && + followTypeAssertionChain(matcherArg).value === false; + } + + if (invertCondition) { + fixes.push( + modifier && modifier.name === ModifierName.not + ? fixer.removeRange([ + modifier.node.property.range[0] - 1, + modifier.node.property.range[1], + ]) + : fixer.insertTextBefore(matcher.node.property, 'not.'), + ); + } + + return fixes; + }, + }); + }, + }; + }, +});