Skip to content

feat(rule): prefer expect queryBy #22

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

Merged
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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,14 @@ To enable this configuration use the `extends` property in your

## Supported Rules

| Rule | Description | Configurations | Fixable |
| -------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------- | ------------------ |
| [await-async-query](docs/rules/await-async-query.md) | Enforce async queries to have proper `await` | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | |
| [no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
| Rule | Description | Configurations | Fixable |
| -------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------- | ------------------ |
| [await-async-query](docs/rules/await-async-query.md) | Enforce async queries to have proper `await` | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | |
| [no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
| [prefer-expect-query-by](docs/rules/prefer-expect-query-by.md) | Disallow the use of `expect(getBy*)` | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |

[build-badge]: https://img.shields.io/travis/Belco90/eslint-plugin-testing-library?style=flat-square
[build-url]: https://travis-ci.org/belco90/eslint-plugin-testing-library
Expand Down
61 changes: 61 additions & 0 deletions docs/rules/prefer-expect-query-by.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Disallow the use of `expect(getBy*)` (prefer-expect-query-by)

The (DOM) Testing Library support three types of queries: `getBy*` and `queryBy*`. Using `getBy*` throws an error in case the element is not found. This is useful when using method like `waitForElement`, which are `async` functions that will wait for the element to be found until a certain timeout, after that the test will fail.
However, when trying to assert if an element is not in the document, we can't use `getBy*` as the test will fail immediately. Instead it is recommended to use `queryBy*`, which does not throw and therefore we can assert that e.g. `expect(queryByText("Foo")).not.toBeInTheDocument()`.

> The same applies for the `getAll*` and `queryAll*` queries.

## Rule details

This rule gives a notification whenever `expect` is used with one of the query functions that throw an error if the element is not found.

This rule is enabled by default.

Examples of **incorrect** code for this rule:

```js
test('some test', () => {
const { getByText, getAllByText } = render(<App />);
expect(getByText('Foo')).toBeInTheDocument();
expect(getAllByText('Foo')[0]).toBeInTheDocument();
expect(getByText('Foo')).not.toBeInTheDocument();
expect(getAllByText('Foo')[0]).not.toBeInTheDocument();
});
```

```js
test('some test', () => {
const rendered = render(<App />);
expect(rendered.getByText('Foo')).toBeInTheDocument();
expect(rendered.getAllByText('Foo')[0]).toBeInTheDocument();
expect(rendered.getByText('Foo')).not.toBeInTheDocument();
expect(rendered.getAllByText('Foo')[0]).not.toBeInTheDocument();
});
```

Examples of **correct** code for this rule:

```js
test('some test', () => {
const { queryByText, queryAllByText } = render(<App />);
expect(queryByText('Foo')).toBeInTheDocument();
expect(queryAllByText('Foo')[0]).toBeInTheDocument();
expect(queryByText('Foo')).not.toBeInTheDocument();
expect(queryAllByText('Foo')[0]).not.toBeInTheDocument();
});
```

```js
test('some test', () => {
const rendered = render(<App />);
expect(rendered.queryByText('Foo')).toBeInTheDocument();
expect(rendered.queryAllByText('Foo')[0]).toBeInTheDocument();
expect(rendered.queryByText('Foo')).not.toBeInTheDocument();
expect(rendered.queryAllByText('Foo')[0]).not.toBeInTheDocument();
});
```

## Further Reading

- [Appearance and Disappearance](https://testing-library.com/docs/guide-disappearance#asserting-elements-are-not-present)
- [Testing Library queries cheatsheet](https://testing-library.com/docs/dom-testing-library/cheatsheet#queries)
2 changes: 2 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ const rules = {
'no-await-sync-query': require('./rules/no-await-sync-query'),
'no-debug': require('./rules/no-debug'),
'no-dom-import': require('./rules/no-dom-import'),
'prefer-expect-query-by': require('./rules/prefer-expect-query-by'),
};

const recommendedRules = {
'testing-library/await-async-query': 'error',
'testing-library/no-await-sync-query': 'error',
'testing-library/prefer-expect-query-by': 'error',
};

module.exports = {
Expand Down
102 changes: 102 additions & 0 deletions lib/rules/prefer-expect-query-by.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

const { getDocsUrl } = require('../utils');

const AST_NODE_TYPES = {
Identifier: 'Identifier',
MemberExpression: 'MemberExpression',
};

function isIdentifier(node) {
return node.type === AST_NODE_TYPES.Identifier;
}

function isMemberExpression(node) {
return node.type === AST_NODE_TYPES.MemberExpression;
}

function isUsingWrongQueries(node) {
return node.name.startsWith('getBy') || node.name.startsWith('getAllBy');
}

function isNotNullOrUndefined(input) {
return input != null;
}

function mapNodesForWrongGetByQuery(node) {
const nodeArguments = node.arguments;
return nodeArguments
.map(arg => {
if (!arg.callee) {
return null;
}
// Example: `expect(rendered.getBy*)`
if (isMemberExpression(arg.callee)) {
const node = arg.callee.property;
if (isIdentifier(node) && isUsingWrongQueries(node)) {
return node;
}
return null;
}

// Example: `expect(getBy*)`
if (isIdentifier(arg.callee) && isUsingWrongQueries(arg.callee)) {
return arg.callee;
}

return null;
})
.filter(isNotNullOrUndefined);
}

function hasExpectWithWrongGetByQuery(node) {
if (
node.callee &&
node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === 'expect' &&
node.arguments
) {
const nodesGetBy = mapNodesForWrongGetByQuery(node);
return nodesGetBy.length > 0;
}
return false;
}

module.exports = {
meta: {
docs: {
category: 'Best Practices',
description: 'Disallow using getBy* queries in expect calls',
recommended: 'error',
url: getDocsUrl('prefer-expect-query-by'),
},
messages: {
expectQueryBy:
'Using `expect(getBy*)` is not recommended, use `expect(queryBy*)` instead.',
},
schema: [],
type: 'suggestion',
fixable: null,
},

create: context => ({
CallExpression(node) {
if (hasExpectWithWrongGetByQuery(node)) {
// const nodesGetBy = mapNodesForWrongGetByQuery(node);
context.report({
node: node.callee,
messageId: 'expectQueryBy',
// TODO: we keep the autofixing disabled for now, until we figure out
// a better way to amend for the edge cases.
// See also the related discussion: https://github.com/Belco90/eslint-plugin-testing-library/pull/22#discussion_r335394402
// fix(fixer) {
// return fixer.replaceText(
// nodesGetBy[0],
// nodesGetBy[0].name.replace(/^(get(All)?(.*))$/, 'query$2$3')
// );
// },
});
}
},
}),
};
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions tests/__snapshots__/index.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Object {
"error",
"angular",
],
"testing-library/prefer-expect-query-by": "error",
},
}
`;
Expand All @@ -30,6 +31,7 @@ Object {
"error",
"react",
],
"testing-library/prefer-expect-query-by": "error",
},
}
`;
Expand All @@ -42,6 +44,7 @@ Object {
"rules": Object {
"testing-library/await-async-query": "error",
"testing-library/no-await-sync-query": "error",
"testing-library/prefer-expect-query-by": "error",
},
}
`;
Expand All @@ -60,6 +63,7 @@ Object {
"error",
"vue",
],
"testing-library/prefer-expect-query-by": "error",
},
}
`;
58 changes: 58 additions & 0 deletions tests/lib/rules/prefer-expect-query-by.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use strict';

const RuleTester = require('eslint').RuleTester;
const rule = require('../../../lib/rules/prefer-expect-query-by');
const { ALL_QUERIES_METHODS } = require('../../../lib/utils');

const ruleTester = new RuleTester({
parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
});

const queryByVariants = ALL_QUERIES_METHODS.reduce(
(variants, method) => [
...variants,
...[`query${method}`, `queryAll${method}`],
],
[]
);
const getByVariants = ALL_QUERIES_METHODS.reduce(
(variants, method) => [...variants, ...[`get${method}`, `getAll${method}`]],
[]
);

ruleTester.run('prefer-expect-query-by', rule, {
valid: queryByVariants.reduce(
(validRules, queryName) => [
...validRules,
{ code: `expect(${queryName}('Hello')).toBeInTheDocument()` },
{ code: `expect(rendered.${queryName}('Hello')).toBeInTheDocument()` },
{ code: `expect(${queryName}('Hello')).not.toBeInTheDocument()` },
{
code: `expect(rendered.${queryName}('Hello')).not.toBeInTheDocument()`,
},
],
[]
),
invalid: getByVariants.reduce(
(invalidRules, queryName) => [
...invalidRules,
{
code: `expect(${queryName}('Hello')).toBeInTheDocument()`,
errors: [{ messageId: 'expectQueryBy' }],
},
{
code: `expect(rendered.${queryName}('Hello')).toBeInTheDocument()`,
errors: [{ messageId: 'expectQueryBy' }],
},
{
code: `expect(${queryName}('Hello')).not.toBeInTheDocument()`,
errors: [{ messageId: 'expectQueryBy' }],
},
{
code: `expect(rendered.${queryName}('Hello')).not.toBeInTheDocument()`,
errors: [{ messageId: 'expectQueryBy' }],
},
],
[]
),
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You also need to add some tests for autofixing feature, you can find some examples in tests/lib/rules/no-dom-import.js and again @thomlom can help you with it if you need something else.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah didn't know that. I added it now. Thanks

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some tests are going to be needed for sure, something like that:

    {
      code: `
        const message = getByText('Hello')
        expect(message).toBeInTheDocument() 
      `,
      errors: [{ messageId: 'expectQueryBy' }],
      output: `
        const message = queryByText('Hello')
        expect(message).toBeInTheDocument()
      `,
    },

If auto-fixing is complex technically, I think we can leave it as an improvement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomlom those specific tests for matched elements saved in a var are not necessary, but I found an issue with the current autofix so probably we need to leave it as an improvement.

@emmenko you are fixing the query used inside expect statement, but you also need to take care about if the query is imported or not.

Let's say I have this test:

const { getByText } = render(<Foo />);
// ...
expect(getByText('bar')).not.toBeInTheDocument();

would be replaced by

const { getByText } = render(<Foo />);
// ...
expect(queryByText('bar')).not.toBeInTheDocument();

but it's not 100% fixed as queryByText is not defined so you would have to destructure/get it from render (which could be harder than it seems as it should be get from destructured object or var declared by the user).

And another case:

const { getByText, queryByText } = render(<Foo />);
// ...
expect(getByText('bar')).not.toBeInTheDocument();

would be replaced by

const { getByText, queryByText } = render(<Foo />);
// ...
expect(queryByText('bar')).not.toBeInTheDocument();

so here queryByText is already defined and rule doesn't have to change anything related to this.

So here we have to 1) improve the fix function to take care of the new query being defined or not or 2) ignore fixing the rule for now. I'm happy with option 2 and leave the autofix for future improvement as implement the proper autofix here could take longer than expected.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing rules do not need to account for other linting errors. As stated in the eslint docs

Since all rules are run again after the initial round of fixes is applied, it’s not necessary for a rule to check whether the code style of a fix will cause errors to be reported by another rule

image

So from my understanding, this is totally fine how the rule autofix is implemented regarding its concerns.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I understand what you mean, but even if it's explained in the rule info I don't want to leave this autofix with the possibility of breaking the code as it would harm the developer experience.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally get what you mean and I agree on the fact that fixing rules are only useful the first time.

But this plugin is new and the testing library's ecosystem is still growing. There are some best practices emerging but I think it's still not clear for newcomers to get how to make the most of Testing Library. My concern is that people will rely on this plugin to implement best practices for them automatically, possibly break their tests and have a bad experience with it.
Even if we state it in the docs, this rule will be in the recommended ones. And I'm pretty sure there will be people who will:

  1. Install the plugin (and don't care of the docs since they just want the recommended ones)
  2. Run eslint --fix
  3. See their tests break
  4. Remove the plugin
  5. Checkout the code

I prefer to have a limited experience that leaves you the manual work instead of a full experience that might cause your code to break.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you want to remove/disable the autofix for now? It's fine by me, I just wanted to share my perspective on this topic before we decide on how to proceed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course! This is not about what I want or something like that :) really happy to have your point of view here. Better to remove the fixing implementation (including the badge and the output in the tests, sorry about the last one as I asked that), and think about it in a future issue. For first rules we added to the plugin we also skipped the autofix for most of them as there are some cases we didn't know what to do or it took longer than expected to be implemented, so it's better to go bit by bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's disabled here: 0fd4a6c

I kept it commented out, so that we have a starting point in case we want to pick it up in the future.