Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7e2f5e3

Browse files
authoredAug 31, 2020
feat: new no-wait-for-snapshot rule (#223)
Closes: #214
1 parent 636273a commit 7e2f5e3

File tree

6 files changed

+404
-0
lines changed

6 files changed

+404
-0
lines changed
 

‎README.md

+4
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
[![Tweet][tweet-badge]][tweet-url]
2424

2525
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
26+
2627
[![All Contributors](https://img.shields.io/badge/all_contributors-31-orange.svg?style=flat-square)](#contributors-)
28+
2729
<!-- ALL-CONTRIBUTORS-BADGE:END -->
2830

2931
## Installation
@@ -143,6 +145,7 @@ To enable this configuration use the `extends` property in your
143145
| [no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | |
144146
| [no-render-in-setup](docs/rules/no-render-in-setup.md) | Disallow the use of `render` in setup functions | | |
145147
| [no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | | |
148+
| [no-wait-for-snapshot](docs/rules/no-wait-for-snapshot.md) | Ensures no snapshot is generated inside of a `waitFor` call | | |
146149
| [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
147150
| [prefer-find-by](docs/rules/prefer-find-by.md) | Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
148151
| [prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | |
@@ -222,6 +225,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
222225

223226
<!-- markdownlint-enable -->
224227
<!-- prettier-ignore-end -->
228+
225229
<!-- ALL-CONTRIBUTORS-LIST:END -->
226230

227231
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Ensures no snapshot is generated inside of a `wait` call' (no-wait-for-snapshot)
2+
3+
Ensure that no calls to `toMatchSnapshot` or `toMatchInlineSnapshot` are made from within a `waitFor` method (or any of the other async utility methods).
4+
5+
## Rule Details
6+
7+
The `waitFor()` method runs in a timer loop. So it'll retry every n amount of time.
8+
If a snapshot is generated inside the wait condition, jest will generate one snapshot per loop.
9+
10+
The problem then is the amount of loop ran until the condition is met will vary between different computers (or CI machines). This leads to tests that will regenerate a lot of snapshots until the condition is matched when devs run those tests locally updating the snapshots; e.g devs cannot run `jest -u` locally or it'll generate a lot of invalid snapshots who'll fail during CI.
11+
12+
Note that this lint rule prevents from generating a snapshot from within any of the [async utility methods](https://testing-library.com/docs/dom-testing-library/api-async).
13+
14+
Examples of **incorrect** code for this rule:
15+
16+
```js
17+
const foo = async () => {
18+
// ...
19+
await waitFor(() => expect(container).toMatchSnapshot());
20+
// ...
21+
};
22+
23+
const bar = async () => {
24+
// ...
25+
await waitFor(() => expect(container).toMatchInlineSnapshot());
26+
// ...
27+
};
28+
29+
const baz = async () => {
30+
// ...
31+
await wait(() => {
32+
expect(container).toMatchSnapshot();
33+
});
34+
// ...
35+
};
36+
```
37+
38+
Examples of **correct** code for this rule:
39+
40+
```js
41+
const foo = () => {
42+
// ...
43+
expect(container).toMatchSnapshot();
44+
// ...
45+
};
46+
47+
const bar = () => {
48+
// ...
49+
expect(container).toMatchInlineSnapshot();
50+
// ...
51+
};
52+
```
53+
54+
## Further Reading
55+
56+
- [Async Utilities](https://testing-library.com/docs/dom-testing-library/api-async)

‎lib/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import noDomImport from './rules/no-dom-import';
88
import noManualCleanup from './rules/no-manual-cleanup';
99
import noRenderInSetup from './rules/no-render-in-setup';
1010
import noWaitForEmptyCallback from './rules/no-wait-for-empty-callback';
11+
import noWaitForSnapshot from './rules/no-wait-for-snapshot';
1112
import preferExplicitAssert from './rules/prefer-explicit-assert';
1213
import preferPresenceQueries from './rules/prefer-presence-queries';
1314
import preferScreenQueries from './rules/prefer-screen-queries';
@@ -25,6 +26,7 @@ const rules = {
2526
'no-manual-cleanup': noManualCleanup,
2627
'no-render-in-setup': noRenderInSetup,
2728
'no-wait-for-empty-callback': noWaitForEmptyCallback,
29+
'no-wait-for-snapshot': noWaitForSnapshot,
2830
'prefer-explicit-assert': preferExplicitAssert,
2931
'prefer-find-by': preferFindBy,
3032
'prefer-presence-queries': preferPresenceQueries,

‎lib/node-utils.ts

+2
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export function findClosestCallExpressionNode(
8484
return node;
8585
}
8686

87+
if(!node.parent) return null
88+
8789
return findClosestCallExpressionNode(node.parent);
8890
}
8991

‎lib/rules/no-wait-for-snapshot.ts

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { getDocsUrl, ASYNC_UTILS, LIBRARY_MODULES } from '../utils';
3+
import {
4+
findClosestCallExpressionNode,
5+
isMemberExpression,
6+
} from '../node-utils';
7+
8+
export const RULE_NAME = 'no-wait-for-snapshot';
9+
export type MessageIds = 'noWaitForSnapshot';
10+
type Options = [];
11+
12+
const ASYNC_UTILS_REGEXP = new RegExp(`^(${ASYNC_UTILS.join('|')})$`);
13+
const SNAPSHOT_REGEXP = /^(toMatchSnapshot|toMatchInlineSnapshot)$/;
14+
15+
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
16+
name: RULE_NAME,
17+
meta: {
18+
type: 'problem',
19+
docs: {
20+
description:
21+
'Ensures no snapshot is generated inside of a `waitFor` call',
22+
category: 'Best Practices',
23+
recommended: 'warn',
24+
},
25+
messages: {
26+
noWaitForSnapshot:
27+
"A snapshot can't be generated inside of a `{{ name }}` call",
28+
},
29+
fixable: null,
30+
schema: [],
31+
},
32+
defaultOptions: [],
33+
34+
create(context) {
35+
const asyncUtilsUsage: Array<{
36+
node: TSESTree.Identifier | TSESTree.MemberExpression;
37+
name: string;
38+
}> = [];
39+
const importedAsyncUtils: string[] = [];
40+
const snapshotUsage: TSESTree.Identifier[] = [];
41+
42+
return {
43+
'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'(
44+
node: TSESTree.Node
45+
) {
46+
const parent = node.parent as TSESTree.ImportDeclaration;
47+
48+
if (!LIBRARY_MODULES.includes(parent.source.value.toString())) return;
49+
50+
let name;
51+
if (node.type === 'ImportSpecifier') {
52+
name = node.imported.name;
53+
}
54+
55+
if (node.type === 'ImportNamespaceSpecifier') {
56+
name = node.local.name;
57+
}
58+
59+
importedAsyncUtils.push(name);
60+
},
61+
[`CallExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`](
62+
node: TSESTree.Identifier
63+
) {
64+
asyncUtilsUsage.push({ node, name: node.name });
65+
},
66+
[`CallExpression > MemberExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`](
67+
node: TSESTree.Identifier
68+
) {
69+
const memberExpression = node.parent as TSESTree.MemberExpression;
70+
const identifier = memberExpression.object as TSESTree.Identifier;
71+
const memberExpressionName = identifier.name;
72+
73+
asyncUtilsUsage.push({
74+
node: memberExpression,
75+
name: memberExpressionName,
76+
});
77+
},
78+
[`Identifier[name=${SNAPSHOT_REGEXP}]`](node: TSESTree.Identifier) {
79+
snapshotUsage.push(node);
80+
},
81+
'Program:exit'() {
82+
const testingLibraryUtilUsage = asyncUtilsUsage.filter(usage => {
83+
if (isMemberExpression(usage.node)) {
84+
const object = usage.node.object as TSESTree.Identifier;
85+
86+
return importedAsyncUtils.includes(object.name);
87+
}
88+
89+
return importedAsyncUtils.includes(usage.name);
90+
});
91+
92+
function getClosestAsyncUtil(
93+
asyncUtilUsage: {
94+
node: TSESTree.Identifier | TSESTree.MemberExpression;
95+
name: string;
96+
},
97+
node: TSESTree.Node
98+
) {
99+
let callExpression = findClosestCallExpressionNode(node);
100+
while (callExpression != null) {
101+
if (callExpression.callee === asyncUtilUsage.node)
102+
return asyncUtilUsage;
103+
callExpression = findClosestCallExpressionNode(
104+
callExpression.parent
105+
);
106+
}
107+
return null;
108+
}
109+
110+
snapshotUsage.forEach(node => {
111+
testingLibraryUtilUsage.forEach(asyncUtilUsage => {
112+
const closestAsyncUtil = getClosestAsyncUtil(asyncUtilUsage, node);
113+
if (closestAsyncUtil != null) {
114+
let name;
115+
if (isMemberExpression(closestAsyncUtil.node)) {
116+
name = (closestAsyncUtil.node.property as TSESTree.Identifier)
117+
.name;
118+
} else {
119+
name = closestAsyncUtil.name;
120+
}
121+
context.report({
122+
node,
123+
messageId: 'noWaitForSnapshot',
124+
data: { name },
125+
});
126+
}
127+
});
128+
});
129+
},
130+
};
131+
},
132+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { createRuleTester } from '../test-utils';
2+
import rule, { RULE_NAME } from '../../../lib/rules/no-wait-for-snapshot';
3+
import { ASYNC_UTILS } from '../../../lib/utils';
4+
5+
const ruleTester = createRuleTester();
6+
7+
ruleTester.run(RULE_NAME, rule, {
8+
valid: [
9+
...ASYNC_UTILS.map(asyncUtil => ({
10+
code: `
11+
import { ${asyncUtil} } from '@testing-library/dom';
12+
test('snapshot calls outside of ${asyncUtil} are valid', () => {
13+
expect(foo).toMatchSnapshot()
14+
await ${asyncUtil}(() => expect(foo).toBeDefined())
15+
expect(foo).toMatchInlineSnapshot()
16+
})
17+
`,
18+
})),
19+
...ASYNC_UTILS.map(asyncUtil => ({
20+
code: `
21+
import { ${asyncUtil} } from '@testing-library/dom';
22+
test('snapshot calls outside of ${asyncUtil} are valid', () => {
23+
expect(foo).toMatchSnapshot()
24+
await ${asyncUtil}(() => {
25+
expect(foo).toBeDefined()
26+
})
27+
expect(foo).toMatchInlineSnapshot()
28+
})
29+
`,
30+
})),
31+
...ASYNC_UTILS.map(asyncUtil => ({
32+
code: `
33+
import * as asyncUtils from '@testing-library/dom';
34+
test('snapshot calls outside of ${asyncUtil} are valid', () => {
35+
expect(foo).toMatchSnapshot()
36+
await asyncUtils.${asyncUtil}(() => expect(foo).toBeDefined())
37+
expect(foo).toMatchInlineSnapshot()
38+
})
39+
`,
40+
})),
41+
...ASYNC_UTILS.map(asyncUtil => ({
42+
code: `
43+
import * as asyncUtils from '@testing-library/dom';
44+
test('snapshot calls outside of ${asyncUtil} are valid', () => {
45+
expect(foo).toMatchSnapshot()
46+
await asyncUtils.${asyncUtil}(() => {
47+
expect(foo).toBeDefined()
48+
})
49+
expect(foo).toMatchInlineSnapshot()
50+
})
51+
`,
52+
})),
53+
...ASYNC_UTILS.map(asyncUtil => ({
54+
code: `
55+
import { ${asyncUtil} } from 'some-other-library';
56+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
57+
await ${asyncUtil}(() => expect(foo).toMatchSnapshot());
58+
});
59+
`,
60+
})),
61+
...ASYNC_UTILS.map(asyncUtil => ({
62+
code: `
63+
import { ${asyncUtil} } from 'some-other-library';
64+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
65+
await ${asyncUtil}(() => {
66+
expect(foo).toMatchSnapshot()
67+
});
68+
});
69+
`,
70+
})),
71+
...ASYNC_UTILS.map(asyncUtil => ({
72+
code: `
73+
import * as asyncUtils from 'some-other-library';
74+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
75+
await asyncUtils.${asyncUtil}(() => expect(foo).toMatchSnapshot());
76+
});
77+
`,
78+
})),
79+
...ASYNC_UTILS.map(asyncUtil => ({
80+
code: `
81+
import * as asyncUtils from 'some-other-library';
82+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
83+
await asyncUtils.${asyncUtil}(() => {
84+
expect(foo).toMatchSnapshot()
85+
});
86+
});
87+
`,
88+
})),
89+
...ASYNC_UTILS.map(asyncUtil => ({
90+
code: `
91+
import { ${asyncUtil} } from 'some-other-library';
92+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
93+
await ${asyncUtil}(() => expect(foo).toMatchInlineSnapshot());
94+
});
95+
`,
96+
})),
97+
...ASYNC_UTILS.map(asyncUtil => ({
98+
code: `
99+
import { ${asyncUtil} } from 'some-other-library';
100+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
101+
await ${asyncUtil}(() => {
102+
expect(foo).toMatchInlineSnapshot()
103+
});
104+
});
105+
`,
106+
})),
107+
...ASYNC_UTILS.map(asyncUtil => ({
108+
code: `
109+
import * as asyncUtils from 'some-other-library';
110+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
111+
await asyncUtils.${asyncUtil}(() => expect(foo).toMatchInlineSnapshot());
112+
});
113+
`,
114+
})),
115+
...ASYNC_UTILS.map(asyncUtil => ({
116+
code: `
117+
import * as asyncUtils from 'some-other-library';
118+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
119+
await asyncUtils.${asyncUtil}(() => {
120+
expect(foo).toMatchInlineSnapshot()
121+
});
122+
});
123+
`,
124+
})),
125+
],
126+
invalid: [
127+
...ASYNC_UTILS.map(asyncUtil => ({
128+
code: `
129+
import { ${asyncUtil} } from '@testing-library/dom';
130+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
131+
await ${asyncUtil}(() => expect(foo).toMatchSnapshot());
132+
});
133+
`,
134+
errors: [{ line: 4, messageId: 'noWaitForSnapshot' }],
135+
})),
136+
...ASYNC_UTILS.map(asyncUtil => ({
137+
code: `
138+
import { ${asyncUtil} } from '@testing-library/dom';
139+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
140+
await ${asyncUtil}(() => {
141+
expect(foo).toMatchSnapshot()
142+
});
143+
});
144+
`,
145+
errors: [{ line: 5, messageId: 'noWaitForSnapshot' }],
146+
})),
147+
...ASYNC_UTILS.map(asyncUtil => ({
148+
code: `
149+
import * as asyncUtils from '@testing-library/dom';
150+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
151+
await asyncUtils.${asyncUtil}(() => expect(foo).toMatchSnapshot());
152+
});
153+
`,
154+
errors: [{ line: 4, messageId: 'noWaitForSnapshot' }],
155+
})),
156+
...ASYNC_UTILS.map(asyncUtil => ({
157+
code: `
158+
import * as asyncUtils from '@testing-library/dom';
159+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
160+
await asyncUtils.${asyncUtil}(() => {
161+
expect(foo).toMatchSnapshot()
162+
});
163+
});
164+
`,
165+
errors: [{ line: 5, messageId: 'noWaitForSnapshot' }],
166+
})),
167+
...ASYNC_UTILS.map(asyncUtil => ({
168+
code: `
169+
import { ${asyncUtil} } from '@testing-library/dom';
170+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
171+
await ${asyncUtil}(() => expect(foo).toMatchInlineSnapshot());
172+
});
173+
`,
174+
errors: [{ line: 4, messageId: 'noWaitForSnapshot' }],
175+
})),
176+
...ASYNC_UTILS.map(asyncUtil => ({
177+
code: `
178+
import { ${asyncUtil} } from '@testing-library/dom';
179+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
180+
await ${asyncUtil}(() => {
181+
expect(foo).toMatchInlineSnapshot()
182+
});
183+
});
184+
`,
185+
errors: [{ line: 5, messageId: 'noWaitForSnapshot' }],
186+
})),
187+
...ASYNC_UTILS.map(asyncUtil => ({
188+
code: `
189+
import * as asyncUtils from '@testing-library/dom';
190+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
191+
await asyncUtils.${asyncUtil}(() => expect(foo).toMatchInlineSnapshot());
192+
});
193+
`,
194+
errors: [{ line: 4, messageId: 'noWaitForSnapshot' }],
195+
})),
196+
...ASYNC_UTILS.map(asyncUtil => ({
197+
code: `
198+
import * as asyncUtils from '@testing-library/dom';
199+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
200+
await asyncUtils.${asyncUtil}(() => {
201+
expect(foo).toMatchInlineSnapshot()
202+
});
203+
});
204+
`,
205+
errors: [{ line: 5, messageId: 'noWaitForSnapshot' }],
206+
})),
207+
],
208+
});

0 commit comments

Comments
 (0)
Please sign in to comment.