Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create no-restricted-matchers rule #575

Merged
merged 1 commit into from May 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -147,6 +147,7 @@ installations requiring long-term consistency.
| [no-jest-import](docs/rules/no-jest-import.md) | Disallow importing Jest | ![recommended][] | |
| [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** | ![recommended][] | |
| [no-restricted-matchers](docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers | | |
| [no-standalone-expect](docs/rules/no-standalone-expect.md) | Prevents expects that are outside of an it or test block. | ![recommended][] | |
| [no-test-callback](docs/rules/no-test-callback.md) | Avoid using a callback in asynchronous tests | ![recommended][] | ![fixable][] |
| [no-test-prefixes](docs/rules/no-test-prefixes.md) | Use `.only` and `.skip` over `f` and `x` | ![recommended][] | ![fixable][] |
Expand Down
47 changes: 47 additions & 0 deletions docs/rules/no-restricted-matchers.md
@@ -0,0 +1,47 @@
# Disallow specific matchers & modifiers (`no-restricted-matchers`)

This rule bans specific matchers & modifiers from being used, and can suggest
alternatives.

## Rule Details

Bans are expressed in the form of a map, with the value being either a string
message to be shown, or `null` if the default rule message should be used.

Both matchers, modifiers, and chains of the two are checked, allowing for
specific variations of a matcher to be banned if desired.

By default, this map is empty, meaning no matchers or modifiers are banned.

For example:

```json
{
"jest/no-restricted-matchers": [
"error",
{
"toBeFalsy": null,
"resolves": "Use `expect(await promise)` instead.",
"not.toHaveBeenCalledWith": null
}
]
}
```

Examples of **incorrect** code for this rule with the above configuration

```js
it('is false', () => {
expect(a).toBeFalsy();
});

it('resolves', async () => {
await expect(myPromise()).resolves.toBe(true);
});

describe('when an error happens', () => {
it('does not upload the file', async () => {
expect(uploadFileMock).not.toHaveBeenCalledWith('file.name');
});
});
```
1 change: 1 addition & 0 deletions src/__tests__/__snapshots__/rules.test.ts.snap
Expand Up @@ -28,6 +28,7 @@ Object {
"jest/no-jest-import": "error",
"jest/no-large-snapshots": "error",
"jest/no-mocks-import": "error",
"jest/no-restricted-matchers": "error",
"jest/no-standalone-expect": "error",
"jest/no-test-callback": "error",
"jest/no-test-prefixes": "error",
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/rules.test.ts
Expand Up @@ -3,7 +3,7 @@ import { resolve } from 'path';
import plugin from '../';

const ruleNames = Object.keys(plugin.rules);
const numberOfRules = 41;
const numberOfRules = 42;

describe('rules', () => {
it('should have a corresponding doc for each rule', () => {
Expand Down
185 changes: 185 additions & 0 deletions src/rules/__tests__/no-restricted-matchers.test.ts
@@ -0,0 +1,185 @@
import { TSESLint } from '@typescript-eslint/experimental-utils';
import resolveFrom from 'resolve-from';
import rule from '../no-restricted-matchers';

const ruleTester = new TSESLint.RuleTester({
parser: resolveFrom(require.resolve('eslint'), 'espree'),
parserOptions: {
ecmaVersion: 2017,
},
});

ruleTester.run('no-restricted-matchers', rule, {
valid: [
'expect(a).toHaveBeenCalled()',
'expect(a).not.toHaveBeenCalled()',
'expect(a).toHaveBeenCalledTimes()',
'expect(a).toHaveBeenCalledWith()',
'expect(a).toHaveBeenLastCalledWith()',
'expect(a).toHaveBeenNthCalledWith()',
'expect(a).toHaveReturned()',
'expect(a).toHaveReturnedTimes()',
'expect(a).toHaveReturnedWith()',
'expect(a).toHaveLastReturnedWith()',
'expect(a).toHaveNthReturnedWith()',
'expect(a).toThrow()',
'expect(a).rejects;',
'expect(a);',
{
code: 'expect(a).resolves',
options: [{ not: null }],
},
{
code: 'expect(a).toBe(b)',
options: [{ 'not.toBe': null }],
},
{
code: 'expect(a)["toBe"](b)',
options: [{ 'not.toBe': null }],
},
],
invalid: [
{
code: 'expect(a).toBe(b)',
options: [{ toBe: null }],
errors: [
{
messageId: 'restrictedChain',
data: {
message: null,
chain: 'toBe',
},
column: 11,
line: 1,
},
],
},
{
code: 'expect(a)["toBe"](b)',
options: [{ toBe: null }],
errors: [
{
messageId: 'restrictedChain',
data: {
message: null,
chain: 'toBe',
},
column: 11,
line: 1,
},
],
},
{
code: 'expect(a).not',
options: [{ not: null }],
errors: [
{
messageId: 'restrictedChain',
data: {
message: null,
chain: 'not',
},
column: 11,
line: 1,
},
],
},
{
code: 'expect(a).not.toBe(b)',
options: [{ not: null }],
errors: [
{
messageId: 'restrictedChain',
data: {
message: null,
chain: 'not',
},
column: 11,
line: 1,
},
],
},
{
code: 'expect(a).not.toBe(b)',
options: [{ 'not.toBe': null }],
errors: [
{
messageId: 'restrictedChain',
data: {
message: null,
chain: 'not.toBe',
},
endColumn: 19,
column: 11,
line: 1,
},
],
},
{
code: 'expect(a).toBe(b)',
options: [{ toBe: 'Prefer `toStrictEqual` instead' }],
errors: [
{
messageId: 'restrictedChainWithMessage',
data: {
message: 'Prefer `toStrictEqual` instead',
chain: 'toBe',
},
column: 11,
line: 1,
},
],
},
{
code: `
test('some test', async () => {
await expect(Promise.resolve(1)).resolves.toBe(1);
});
`,
options: [{ resolves: 'Use `expect(await promise)` instead.' }],
errors: [
{
messageId: 'restrictedChainWithMessage',
data: {
message: 'Use `expect(await promise)` instead.',
chain: 'resolves',
},
endColumn: 52,
column: 44,
},
],
},
{
code: 'expect(Promise.resolve({})).rejects.toBeFalsy()',
options: [{ toBeFalsy: null }],
errors: [
{
messageId: 'restrictedChain',
data: {
message: null,
chain: 'toBeFalsy',
},
endColumn: 46,
column: 37,
},
],
},
{
code: "expect(uploadFileMock).not.toHaveBeenCalledWith('file.name')",
options: [
{ 'not.toHaveBeenCalledWith': 'Use not.toHaveBeenCalled instead' },
],
errors: [
{
messageId: 'restrictedChainWithMessage',
data: {
message: 'Use not.toHaveBeenCalled instead',
chain: 'not.toHaveBeenCalledWith',
},
endColumn: 48,
column: 24,
},
],
},
],
});
97 changes: 97 additions & 0 deletions src/rules/no-restricted-matchers.ts
@@ -0,0 +1,97 @@
import { createRule, isExpectCall, parseExpectCall } from './utils';

export default createRule<
[Record<string, string | null>],
'restrictedChain' | 'restrictedChainWithMessage'
>({
name: __filename,
meta: {
docs: {
category: 'Best Practices',
description: 'Disallow specific matchers & modifiers',
recommended: false,
},
type: 'suggestion',
schema: [
{
type: 'object',
additionalProperties: {
type: ['string', 'null'],
},
},
],
messages: {
restrictedChain: 'Use of `{{ chain }}` is disallowed',
restrictedChainWithMessage: '{{ message }}',
},
},
defaultOptions: [{}],
create(context, [restrictedChains]) {
return {
CallExpression(node) {
if (!isExpectCall(node)) {
return;
}

const { matcher, modifier } = parseExpectCall(node);

if (matcher) {
const chain = matcher.name;

if (chain in restrictedChains) {
const message = restrictedChains[chain];

context.report({
messageId: message
? 'restrictedChainWithMessage'
: 'restrictedChain',
data: { message, chain },
node: matcher.node.property,
});

return;
}
}

if (modifier) {
const chain = modifier.name;

if (chain in restrictedChains) {
const message = restrictedChains[chain];

context.report({
messageId: message
? 'restrictedChainWithMessage'
: 'restrictedChain',
data: { message, chain },
node: modifier.node.property,
});

return;
}
}

if (matcher && modifier) {
const chain = `${modifier.name}.${matcher.name}`;

if (chain in restrictedChains) {
const message = restrictedChains[chain];

context.report({
messageId: message
? 'restrictedChainWithMessage'
: 'restrictedChain',
data: { message, chain },
loc: {
start: modifier.node.property.loc.start,
end: matcher.node.property.loc.end,
},
});

return;
}
}
},
};
},
});