Skip to content

Commit 292a371

Browse files
authored
feat: add rule unique-test-case-names (#561)
This change adds a new rule to require that any test cases that have names defined, have unique names within each `valid` and `invalid` group. This helps ensure that test logs are unambiguous.
1 parent 7ae7622 commit 292a371

File tree

5 files changed

+243
-1
lines changed

5 files changed

+243
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export default [
108108
| [require-test-case-name](docs/rules/require-test-case-name.md) | require test cases to have a `name` property under certain conditions | | | | |
109109
| [test-case-property-ordering](docs/rules/test-case-property-ordering.md) | require the properties of a test case to be placed in a consistent order | | 🔧 | | |
110110
| [test-case-shorthand-strings](docs/rules/test-case-shorthand-strings.md) | enforce consistent usage of shorthand strings for test cases with no options | | 🔧 | | |
111+
| [unique-test-case-names](docs/rules/unique-test-case-names.md) | enforce that all test cases with names have unique names | | | | |
111112

112113
<!-- end auto-generated rules list -->
113114

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Enforce that all test cases with names have unique names (`eslint-plugin/unique-test-case-names`)
2+
3+
<!-- end auto-generated rule header -->
4+
5+
This rule enforces that any test cases that have names defined, have unique names within their `valid` and `invalid` arrays.
6+
7+
## Rule Details
8+
9+
This rule aims to ensure test suites are producing logs in a form that make it easy to identify failing tests, when they occur.
10+
For thoroughly tested rules, it's not uncommon for test cases to have names defined so that they're easily distinguishable in the test log output.
11+
Requiring that, within each `valid` and `invalid` group, any test cases with names have unique names, it ensures the test logs are unambiguous.
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```js
16+
new RuleTester().run('foo', bar, {
17+
valid: [
18+
{
19+
code: 'nin',
20+
name: 'test case 1',
21+
},
22+
{
23+
code: 'smz',
24+
name: 'test case 1',
25+
},
26+
],
27+
invalid: [
28+
{
29+
code: 'foo',
30+
errors: ['Error'],
31+
name: 'test case 2',
32+
},
33+
{
34+
code: 'bar',
35+
errors: ['Error'],
36+
name: 'test case 2',
37+
},
38+
],
39+
});
40+
```
41+
42+
Examples of **correct** code for this rule:
43+
44+
```js
45+
new RuleTester().run('foo', bar, {
46+
valid: [
47+
{
48+
code: 'nin',
49+
name: 'test case 1',
50+
},
51+
{
52+
code: 'smz',
53+
name: 'test case 2',
54+
},
55+
],
56+
invalid: [
57+
{
58+
code: 'foo',
59+
errors: ['Error'],
60+
name: 'test case 1',
61+
},
62+
{
63+
code: 'bar',
64+
errors: ['Error'],
65+
name: 'test case 2',
66+
},
67+
],
68+
});
69+
```
70+
71+
## When Not to Use It
72+
73+
If you aren't concerned with the nature of test logs.

lib/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import requireMetaType from './rules/require-meta-type.ts';
3939
import requireTestCaseName from './rules/require-test-case-name.ts';
4040
import testCasePropertyOrdering from './rules/test-case-property-ordering.ts';
4141
import testCaseShorthandStrings from './rules/test-case-shorthand-strings.ts';
42-
42+
import uniqueTestCaseNames from './rules/unique-test-case-names.ts';
4343
const require = createRequire(import.meta.url);
4444

4545
const packageMetadata = require('../package.json') as {
@@ -119,6 +119,7 @@ const allRules = {
119119
'require-test-case-name': requireTestCaseName,
120120
'test-case-property-ordering': testCasePropertyOrdering,
121121
'test-case-shorthand-strings': testCaseShorthandStrings,
122+
'unique-test-case-names': uniqueTestCaseNames,
122123
} satisfies Record<string, Rule.RuleModule>;
123124

124125
const plugin = {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { Rule } from 'eslint';
2+
3+
import { evaluateObjectProperties, getKeyName, getTestInfo } from '../utils.ts';
4+
import type { TestInfo } from '../types.ts';
5+
6+
const rule: Rule.RuleModule = {
7+
meta: {
8+
type: 'suggestion',
9+
docs: {
10+
description: 'enforce that all test cases with names have unique names',
11+
category: 'Tests',
12+
recommended: false,
13+
url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/unique-test-case-names.md',
14+
},
15+
schema: [],
16+
messages: {
17+
notUnique:
18+
'This test case name is not unique. All test cases with names should have unique names.',
19+
},
20+
},
21+
22+
create(context) {
23+
const sourceCode = context.sourceCode;
24+
25+
/**
26+
* Validates test cases and reports them if found in violation
27+
* @param cases A list of test case nodes
28+
*/
29+
function validateTestCases(cases: TestInfo['valid']): void {
30+
// Gather all of the information from each test case
31+
const namesSeen = new Set<string>();
32+
const violatingNodes: NonNullable<TestInfo['valid'][number]>[] = [];
33+
34+
cases
35+
.filter((testCase) => !!testCase)
36+
.forEach((testCase) => {
37+
if (testCase.type === 'ObjectExpression') {
38+
for (const property of evaluateObjectProperties(
39+
testCase,
40+
sourceCode.scopeManager,
41+
)) {
42+
if (property.type === 'Property') {
43+
const keyName = getKeyName(
44+
property,
45+
sourceCode.getScope(testCase),
46+
);
47+
if (
48+
keyName === 'name' &&
49+
property.value.type === 'Literal' &&
50+
typeof property.value.value === 'string'
51+
) {
52+
const name = property.value.value;
53+
if (namesSeen.has(name)) {
54+
violatingNodes.push(property.value);
55+
} else {
56+
namesSeen.add(name);
57+
}
58+
break;
59+
}
60+
}
61+
}
62+
}
63+
});
64+
65+
for (const node of violatingNodes) {
66+
context.report({
67+
node,
68+
messageId: 'notUnique',
69+
});
70+
}
71+
}
72+
73+
return {
74+
Program(ast) {
75+
getTestInfo(context, ast).forEach((testInfo) => {
76+
validateTestCases(testInfo.valid);
77+
validateTestCases(testInfo.invalid);
78+
});
79+
},
80+
};
81+
},
82+
};
83+
84+
export default rule;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { RuleTester } from 'eslint';
2+
3+
import rule from '../../../lib/rules/unique-test-case-names.ts';
4+
5+
/**
6+
* Returns the code for some valid test cases
7+
* @param cases The code representation of valid test cases
8+
* @returns Code representing the test cases
9+
*/
10+
function getTestCases(valid: string[], invalid: string[] = []): string {
11+
return `
12+
new RuleTester().run('foo', bar, {
13+
valid: [
14+
${valid.join(',\n ')},
15+
],
16+
invalid: [
17+
${invalid.join(',\n ')},
18+
]
19+
});
20+
`;
21+
}
22+
23+
const errorBuffer = 3; // Lines before the test cases start
24+
25+
const error = (line?: number) => ({
26+
messageId: 'notUnique',
27+
...(typeof line === 'number' ? { line } : {}),
28+
});
29+
30+
const ruleTester = new RuleTester({
31+
languageOptions: { sourceType: 'module' },
32+
});
33+
ruleTester.run('unique-test-case-names', rule, {
34+
valid: [
35+
{
36+
code: getTestCases(['"foo"', '"bar"', '"baz"']),
37+
name: 'only shorthand strings',
38+
},
39+
{
40+
code: getTestCases(['"foo"', '"foo"', '"foo"']),
41+
name: 'redundant shorthand strings',
42+
},
43+
{
44+
code: getTestCases(['"foo"', '"bar"', '{ code: "foo" }']),
45+
name: 'shorthand strings and object without name',
46+
},
47+
{
48+
code: getTestCases([
49+
'{ code: "foo" }',
50+
'{ code: "bar", name: "my test" }',
51+
]),
52+
name: 'object without name and with name',
53+
},
54+
{
55+
code: getTestCases([
56+
'{ code: "foo", name: "my test" }',
57+
'{ code: "bar", name: "my other test" }',
58+
]),
59+
name: 'object with unique names',
60+
},
61+
{
62+
code: getTestCases(['foo']),
63+
name: 'non-string, non-object test case (identifier)',
64+
},
65+
{
66+
code: getTestCases(['foo()']),
67+
name: 'non-string, non-object test case (function)',
68+
},
69+
],
70+
71+
invalid: [
72+
{
73+
code: getTestCases([
74+
'{ code: "foo", name: "my test" }',
75+
'{ code: "bar", name: "my other test" }',
76+
'{ code: "baz", name: "my test" }',
77+
'{ code: "bla", name: "my other test" }',
78+
]),
79+
errors: [error(errorBuffer + 3), error(errorBuffer + 4)],
80+
name: 'object with non-unique names',
81+
},
82+
],
83+
});

0 commit comments

Comments
 (0)