Skip to content

Commit d854723

Browse files
authoredFeb 6, 2022
feat: create prefer-snapshot-hint rule (#1012)
* feat: create `prefer-snapshot-hint` rule * feat(prefer-snapshot-hint): check nested scope for multiple snapshot matchers * fix: update import * test: update number
1 parent ac15932 commit d854723

File tree

6 files changed

+1020
-1
lines changed

6 files changed

+1020
-1
lines changed
 

‎README.md

+1
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ installations requiring long-term consistency.
183183
| [prefer-expect-resolves](docs/rules/prefer-expect-resolves.md) | Prefer `await expect(...).resolves` over `expect(await ...)` syntax | | ![fixable][] |
184184
| [prefer-hooks-on-top](docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | |
185185
| [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | ![fixable][] |
186+
| [prefer-snapshot-hint](docs/rules/prefer-snapshot-hint.md) | Prefer including a hint with external snapshots | | |
186187
| [prefer-spy-on](docs/rules/prefer-spy-on.md) | Suggest using `jest.spyOn()` | | ![fixable][] |
187188
| [prefer-strict-equal](docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | ![suggest][] |
188189
| [prefer-to-be](docs/rules/prefer-to-be.md) | Suggest using `toBe()` for primitive literals | ![style][] | ![fixable][] |

‎docs/rules/prefer-snapshot-hint.md

+188
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Prefer including a hint with external snapshots (`prefer-snapshot-hint`)
2+
3+
When working with external snapshot matchers it's considered best practice to
4+
provide a hint (as the last argument to the matcher) describing the expected
5+
snapshot content that will be included in the snapshots name by Jest.
6+
7+
This makes it easier for reviewers to verify the snapshots during review, and
8+
for anyone to know whether an outdated snapshot is the correct behavior before
9+
updating.
10+
11+
## Rule details
12+
13+
This rule looks for any use of an external snapshot matcher (e.g.
14+
`toMatchSnapshot` and `toThrowErrorMatchingSnapshot`) and checks if they include
15+
a snapshot hint.
16+
17+
## Options
18+
19+
### `'always'`
20+
21+
Require a hint to _always_ be provided when using external snapshot matchers.
22+
23+
Examples of **incorrect** code for the `'always'` option:
24+
25+
```js
26+
const snapshotOutput = ({ stdout, stderr }) => {
27+
expect(stdout).toMatchSnapshot();
28+
expect(stderr).toMatchSnapshot();
29+
};
30+
31+
describe('cli', () => {
32+
describe('--version flag', () => {
33+
it('prints the version', async () => {
34+
snapshotOutput(await runCli(['--version']));
35+
});
36+
});
37+
38+
describe('--config flag', () => {
39+
it('reads the config', async () => {
40+
const { stdout, parsedConfig } = await runCli([
41+
'--config',
42+
'jest.config.js',
43+
]);
44+
45+
expect(stdout).toMatchSnapshot();
46+
expect(parsedConfig).toMatchSnapshot();
47+
});
48+
49+
it('prints nothing to stderr', async () => {
50+
const { stderr } = await runCli(['--config', 'jest.config.js']);
51+
52+
expect(stderr).toMatchSnapshot();
53+
});
54+
55+
describe('when the file does not exist', () => {
56+
it('throws an error', async () => {
57+
await expect(
58+
runCli(['--config', 'does-not-exist.js']),
59+
).rejects.toThrowErrorMatchingSnapshot();
60+
});
61+
});
62+
});
63+
});
64+
```
65+
66+
Examples of **correct** code for the `'always'` option:
67+
68+
```js
69+
const snapshotOutput = ({ stdout, stderr }, hints) => {
70+
expect(stdout).toMatchSnapshot({}, `stdout: ${hints.stdout}`);
71+
expect(stderr).toMatchSnapshot({}, `stderr: ${hints.stderr}`);
72+
};
73+
74+
describe('cli', () => {
75+
describe('--version flag', () => {
76+
it('prints the version', async () => {
77+
snapshotOutput(await runCli(['--version']), {
78+
stdout: 'version string',
79+
stderr: 'empty',
80+
});
81+
});
82+
});
83+
84+
describe('--config flag', () => {
85+
it('reads the config', async () => {
86+
const { stdout } = await runCli(['--config', 'jest.config.js']);
87+
88+
expect(stdout).toMatchSnapshot({}, 'stdout: config settings');
89+
});
90+
91+
it('prints nothing to stderr', async () => {
92+
const { stderr } = await runCli(['--config', 'jest.config.js']);
93+
94+
expect(stderr).toMatchInlineSnapshot();
95+
});
96+
97+
describe('when the file does not exist', () => {
98+
it('throws an error', async () => {
99+
await expect(
100+
runCli(['--config', 'does-not-exist.js']),
101+
).rejects.toThrowErrorMatchingSnapshot('stderr: config error');
102+
});
103+
});
104+
});
105+
});
106+
```
107+
108+
### `'multi'` (default)
109+
110+
Require a hint to be provided when there are multiple external snapshot matchers
111+
within the scope (meaning it includes nested calls).
112+
113+
Examples of **incorrect** code for the `'multi'` option:
114+
115+
```js
116+
const snapshotOutput = ({ stdout, stderr }) => {
117+
expect(stdout).toMatchSnapshot();
118+
expect(stderr).toMatchSnapshot();
119+
};
120+
121+
describe('cli', () => {
122+
describe('--version flag', () => {
123+
it('prints the version', async () => {
124+
snapshotOutput(await runCli(['--version']));
125+
});
126+
});
127+
128+
describe('--config flag', () => {
129+
it('reads the config', async () => {
130+
const { stdout, parsedConfig } = await runCli([
131+
'--config',
132+
'jest.config.js',
133+
]);
134+
135+
expect(stdout).toMatchSnapshot();
136+
expect(parsedConfig).toMatchSnapshot();
137+
});
138+
139+
it('prints nothing to stderr', async () => {
140+
const { stderr } = await runCli(['--config', 'jest.config.js']);
141+
142+
expect(stderr).toMatchSnapshot();
143+
});
144+
});
145+
});
146+
```
147+
148+
Examples of **correct** code for the `'multi'` option:
149+
150+
```js
151+
const snapshotOutput = ({ stdout, stderr }, hints) => {
152+
expect(stdout).toMatchSnapshot({}, `stdout: ${hints.stdout}`);
153+
expect(stderr).toMatchSnapshot({}, `stderr: ${hints.stderr}`);
154+
};
155+
156+
describe('cli', () => {
157+
describe('--version flag', () => {
158+
it('prints the version', async () => {
159+
snapshotOutput(await runCli(['--version']), {
160+
stdout: 'version string',
161+
stderr: 'empty',
162+
});
163+
});
164+
});
165+
166+
describe('--config flag', () => {
167+
it('reads the config', async () => {
168+
const { stdout } = await runCli(['--config', 'jest.config.js']);
169+
170+
expect(stdout).toMatchSnapshot();
171+
});
172+
173+
it('prints nothing to stderr', async () => {
174+
const { stderr } = await runCli(['--config', 'jest.config.js']);
175+
176+
expect(stderr).toMatchInlineSnapshot();
177+
});
178+
179+
describe('when the file does not exist', () => {
180+
it('throws an error', async () => {
181+
await expect(
182+
runCli(['--config', 'does-not-exist.js']),
183+
).rejects.toThrowErrorMatchingSnapshot();
184+
});
185+
});
186+
});
187+
});
188+
```

‎src/__tests__/__snapshots__/rules.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Object {
4141
"jest/prefer-expect-resolves": "error",
4242
"jest/prefer-hooks-on-top": "error",
4343
"jest/prefer-lowercase-title": "error",
44+
"jest/prefer-snapshot-hint": "error",
4445
"jest/prefer-spy-on": "error",
4546
"jest/prefer-strict-equal": "error",
4647
"jest/prefer-to-be": "error",

‎src/__tests__/rules.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
22
import { resolve } from 'path';
33
import plugin from '../';
44

5-
const numberOfRules = 46;
5+
const numberOfRules = 47;
66
const ruleNames = Object.keys(plugin.rules);
77
const deprecatedRules = Object.entries(plugin.rules)
88
.filter(([, rule]) => rule.meta.deprecated)

‎src/rules/__tests__/prefer-snapshot-hint.test.ts

+726
Large diffs are not rendered by default.

‎src/rules/prefer-snapshot-hint.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import {
2+
ParsedExpectMatcher,
3+
createRule,
4+
isExpectCall,
5+
parseExpectCall,
6+
} from './utils';
7+
8+
const snapshotMatchers = ['toMatchSnapshot', 'toThrowErrorMatchingSnapshot'];
9+
10+
const isSnapshotMatcher = (matcher: ParsedExpectMatcher) => {
11+
return snapshotMatchers.includes(matcher.name);
12+
};
13+
14+
const isSnapshotMatcherWithoutHint = (matcher: ParsedExpectMatcher) => {
15+
const expectedNumberOfArgumentsWithHint =
16+
1 + Number(matcher.name === 'toMatchSnapshot');
17+
18+
return matcher.arguments?.length !== expectedNumberOfArgumentsWithHint;
19+
};
20+
21+
const messages = {
22+
missingHint: 'You should provide a hint for this snapshot',
23+
};
24+
25+
export default createRule<[('always' | 'multi')?], keyof typeof messages>({
26+
name: __filename,
27+
meta: {
28+
docs: {
29+
category: 'Best Practices',
30+
description: 'Prefer including a hint with external snapshots',
31+
recommended: false,
32+
},
33+
messages,
34+
type: 'suggestion',
35+
schema: [
36+
{
37+
type: 'string',
38+
enum: ['always', 'multi'],
39+
},
40+
],
41+
},
42+
defaultOptions: ['multi'],
43+
create(context, [mode]) {
44+
const snapshotMatchers: ParsedExpectMatcher[] = [];
45+
let expressionDepth = 0;
46+
47+
const reportSnapshotMatchersWithoutHints = () => {
48+
for (const snapshotMatcher of snapshotMatchers) {
49+
if (isSnapshotMatcherWithoutHint(snapshotMatcher)) {
50+
context.report({
51+
messageId: 'missingHint',
52+
node: snapshotMatcher.node.property,
53+
});
54+
}
55+
}
56+
};
57+
58+
const enterExpression = () => {
59+
expressionDepth++;
60+
};
61+
62+
const exitExpression = () => {
63+
expressionDepth--;
64+
65+
if (mode === 'always') {
66+
reportSnapshotMatchersWithoutHints();
67+
snapshotMatchers.length = 0;
68+
}
69+
70+
if (mode === 'multi' && expressionDepth === 0) {
71+
if (snapshotMatchers.length > 1) {
72+
reportSnapshotMatchersWithoutHints();
73+
}
74+
75+
snapshotMatchers.length = 0;
76+
}
77+
};
78+
79+
return {
80+
'Program:exit'() {
81+
enterExpression();
82+
exitExpression();
83+
},
84+
FunctionExpression: enterExpression,
85+
'FunctionExpression:exit': exitExpression,
86+
ArrowFunctionExpression: enterExpression,
87+
'ArrowFunctionExpression:exit': exitExpression,
88+
CallExpression(node) {
89+
if (!isExpectCall(node)) {
90+
return;
91+
}
92+
93+
const { matcher } = parseExpectCall(node);
94+
95+
if (!matcher || !isSnapshotMatcher(matcher)) {
96+
return;
97+
}
98+
99+
snapshotMatchers.push(matcher);
100+
},
101+
};
102+
},
103+
});

0 commit comments

Comments
 (0)
Please sign in to comment.