Skip to content

Commit af9c8ee

Browse files
committed
feat: 🎸 new no-wait-for-snapshot rule
✅ Closes: #214
1 parent 636273a commit af9c8ee

File tree

6 files changed

+299
-0
lines changed

6 files changed

+299
-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!
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Ensures no snapshot is generated inside of a `wait` call' (no-wait-for-snapshot)
2+
3+
Ensure that no calls to `toMatchSnapshot` 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 match 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 wait(() => {
26+
expect(container).toMatchSnapshot();
27+
});
28+
// ...
29+
};
30+
```
31+
32+
Examples of **correct** code for this rule:
33+
34+
```js
35+
const foo = () => {
36+
// ...
37+
expect(container).toMatchSnapshot();
38+
// ...
39+
};
40+
```
41+
42+
## Further Reading
43+
44+
- [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

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { getDocsUrl, ASYNC_UTILS, LIBRARY_MODULES } from '../utils';
3+
import { findClosestCallExpressionNode } from '../node-utils';
4+
5+
export const RULE_NAME = 'no-wait-for-snapshot';
6+
export type MessageIds = 'noWaitForSnapshot';
7+
type Options = [];
8+
9+
const ASYNC_UTILS_REGEXP = new RegExp(`^(${ASYNC_UTILS.join('|')})$`);
10+
11+
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
12+
name: RULE_NAME,
13+
meta: {
14+
type: 'problem',
15+
docs: {
16+
description:
17+
'Ensures no snapshot is generated inside of a `waitFor` call',
18+
category: 'Best Practices',
19+
recommended: 'warn',
20+
},
21+
messages: {
22+
noWaitForSnapshot:
23+
"A snapshot can't be generated inside of a `waitFor` call",
24+
},
25+
fixable: null,
26+
schema: [],
27+
},
28+
defaultOptions: [],
29+
30+
create(context) {
31+
const asyncUtilsUsage: Array<{
32+
node: TSESTree.Identifier | TSESTree.MemberExpression;
33+
name: string;
34+
}> = [];
35+
const importedAsyncUtils: string[] = [];
36+
const snapshotUsage: TSESTree.Identifier[] = [];
37+
38+
return {
39+
'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'(
40+
node: TSESTree.Node
41+
) {
42+
const parent = node.parent as TSESTree.ImportDeclaration;
43+
44+
if (!LIBRARY_MODULES.includes(parent.source.value.toString())) return;
45+
46+
let name;
47+
if (node.type === 'ImportSpecifier') {
48+
name = node.imported.name;
49+
}
50+
51+
if (node.type === 'ImportNamespaceSpecifier') {
52+
name = node.local.name;
53+
}
54+
55+
importedAsyncUtils.push(name);
56+
},
57+
[`CallExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`](
58+
node: TSESTree.Identifier
59+
) {
60+
asyncUtilsUsage.push({ node, name: node.name });
61+
},
62+
[`CallExpression > MemberExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`](
63+
node: TSESTree.Identifier
64+
) {
65+
const memberExpression = node.parent as TSESTree.MemberExpression;
66+
const identifier = memberExpression.object as TSESTree.Identifier;
67+
const memberExpressionName = identifier.name;
68+
69+
asyncUtilsUsage.push({
70+
node: memberExpression,
71+
name: memberExpressionName,
72+
});
73+
},
74+
[`Identifier[name='toMatchSnapshot']`](node: TSESTree.Identifier) {
75+
snapshotUsage.push(node);
76+
},
77+
'Program:exit'() {
78+
const testingLibraryUtilUsage = asyncUtilsUsage.filter(usage => {
79+
if (usage.node.type === 'MemberExpression') {
80+
const object = usage.node.object as TSESTree.Identifier;
81+
82+
return importedAsyncUtils.includes(object.name);
83+
}
84+
85+
return importedAsyncUtils.includes(usage.name);
86+
});
87+
88+
function isWithinAsyncUtil(
89+
asyncUtilUsage: {
90+
node: TSESTree.Identifier | TSESTree.MemberExpression;
91+
name: string;
92+
},
93+
node: TSESTree.Node
94+
) {
95+
let callExpression = findClosestCallExpressionNode(node);
96+
while (callExpression != null) {
97+
if (callExpression.callee === asyncUtilUsage.node) return true;
98+
callExpression = findClosestCallExpressionNode(
99+
callExpression.parent
100+
);
101+
}
102+
return false;
103+
}
104+
105+
snapshotUsage.forEach(node => {
106+
testingLibraryUtilUsage.forEach(asyncUtilUsage => {
107+
if (isWithinAsyncUtil(asyncUtilUsage, node)) {
108+
context.report({ node, messageId: 'noWaitForSnapshot' });
109+
}
110+
});
111+
});
112+
},
113+
};
114+
},
115+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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).toMatchSnapshot()
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).toMatchSnapshot()
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).toMatchSnapshot()
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).toMatchSnapshot()
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+
],
90+
invalid: [
91+
...ASYNC_UTILS.map(asyncUtil => ({
92+
code: `
93+
import { ${asyncUtil} } from '@testing-library/dom';
94+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
95+
await ${asyncUtil}(() => expect(foo).toMatchSnapshot());
96+
});
97+
`,
98+
errors: [{ line: 4, messageId: 'noWaitForSnapshot' }],
99+
})),
100+
...ASYNC_UTILS.map(asyncUtil => ({
101+
code: `
102+
import { ${asyncUtil} } from '@testing-library/dom';
103+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
104+
await ${asyncUtil}(() => {
105+
expect(foo).toMatchSnapshot()
106+
});
107+
});
108+
`,
109+
errors: [{ line: 5, messageId: 'noWaitForSnapshot' }],
110+
})),
111+
...ASYNC_UTILS.map(asyncUtil => ({
112+
code: `
113+
import * as asyncUtils from '@testing-library/dom';
114+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
115+
await asyncUtils.${asyncUtil}(() => expect(foo).toMatchSnapshot());
116+
});
117+
`,
118+
errors: [{ line: 4, messageId: 'noWaitForSnapshot' }],
119+
})),
120+
...ASYNC_UTILS.map(asyncUtil => ({
121+
code: `
122+
import * as asyncUtils from '@testing-library/dom';
123+
test('snapshot calls within ${asyncUtil} are not valid', async () => {
124+
await asyncUtils.${asyncUtil}(() => {
125+
expect(foo).toMatchSnapshot()
126+
});
127+
});
128+
`,
129+
errors: [{ line: 5, messageId: 'noWaitForSnapshot' }],
130+
})),
131+
],
132+
});

0 commit comments

Comments
 (0)