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’ll 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
}
}
},
};
},
});