From eac9b0a3e44285b8e25ce7e90cd62f06fb215916 Mon Sep 17 00:00:00 2001 From: michael faith Date: Fri, 24 Oct 2025 20:45:55 -0500 Subject: [PATCH] feat(unique-test-case-names): add rule to enforce unique test case name 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. --- README.md | 1 + docs/rules/unique-test-case-names.md | 73 ++++++++++++++++++++ lib/index.ts | 3 +- lib/rules/unique-test-case-names.ts | 84 +++++++++++++++++++++++ tests/lib/rules/unique-test-case-names.ts | 83 ++++++++++++++++++++++ 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 docs/rules/unique-test-case-names.md create mode 100644 lib/rules/unique-test-case-names.ts create mode 100644 tests/lib/rules/unique-test-case-names.ts diff --git a/README.md b/README.md index 7c66ad2b..5af4e7b2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ export default [ | [require-test-case-name](docs/rules/require-test-case-name.md) | require test cases to have a `name` property under certain conditions | | | | | | [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 | | 🔧 | | | | [test-case-shorthand-strings](docs/rules/test-case-shorthand-strings.md) | enforce consistent usage of shorthand strings for test cases with no options | | 🔧 | | | +| [unique-test-case-names](docs/rules/unique-test-case-names.md) | enforce that all test cases with names have unique names | | | | | diff --git a/docs/rules/unique-test-case-names.md b/docs/rules/unique-test-case-names.md new file mode 100644 index 00000000..9643c687 --- /dev/null +++ b/docs/rules/unique-test-case-names.md @@ -0,0 +1,73 @@ +# Enforce that all test cases with names have unique names (`eslint-plugin/unique-test-case-names`) + + + +This rule enforces that any test cases that have names defined, have unique names within their `valid` and `invalid` arrays. + +## Rule Details + +This rule aims to ensure test suites are producing logs in a form that make it easy to identify failing tests, when they occur. +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. +Requiring that, within each `valid` and `invalid` group, any test cases with names have unique names, it ensures the test logs are unambiguous. + +Examples of **incorrect** code for this rule: + +```js +new RuleTester().run('foo', bar, { + valid: [ + { + code: 'nin', + name: 'test case 1', + }, + { + code: 'smz', + name: 'test case 1', + }, + ], + invalid: [ + { + code: 'foo', + errors: ['Error'], + name: 'test case 2', + }, + { + code: 'bar', + errors: ['Error'], + name: 'test case 2', + }, + ], +}); +``` + +Examples of **correct** code for this rule: + +```js +new RuleTester().run('foo', bar, { + valid: [ + { + code: 'nin', + name: 'test case 1', + }, + { + code: 'smz', + name: 'test case 2', + }, + ], + invalid: [ + { + code: 'foo', + errors: ['Error'], + name: 'test case 1', + }, + { + code: 'bar', + errors: ['Error'], + name: 'test case 2', + }, + ], +}); +``` + +## When Not to Use It + +If you aren't concerned with the nature of test logs. diff --git a/lib/index.ts b/lib/index.ts index 1b13e8cc..11ba765e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -39,7 +39,7 @@ import requireMetaType from './rules/require-meta-type.ts'; import requireTestCaseName from './rules/require-test-case-name.ts'; import testCasePropertyOrdering from './rules/test-case-property-ordering.ts'; import testCaseShorthandStrings from './rules/test-case-shorthand-strings.ts'; - +import uniqueTestCaseNames from './rules/unique-test-case-names.ts'; const require = createRequire(import.meta.url); const packageMetadata = require('../package.json') as { @@ -119,6 +119,7 @@ const allRules = { 'require-test-case-name': requireTestCaseName, 'test-case-property-ordering': testCasePropertyOrdering, 'test-case-shorthand-strings': testCaseShorthandStrings, + 'unique-test-case-names': uniqueTestCaseNames, } satisfies Record; const plugin = { diff --git a/lib/rules/unique-test-case-names.ts b/lib/rules/unique-test-case-names.ts new file mode 100644 index 00000000..e62fefe4 --- /dev/null +++ b/lib/rules/unique-test-case-names.ts @@ -0,0 +1,84 @@ +import type { Rule } from 'eslint'; + +import { evaluateObjectProperties, getKeyName, getTestInfo } from '../utils.ts'; +import type { TestInfo } from '../types.ts'; + +const rule: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: 'enforce that all test cases with names have unique names', + category: 'Tests', + recommended: false, + url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/unique-test-case-names.md', + }, + schema: [], + messages: { + notUnique: + 'This test case name is not unique. All test cases with names should have unique names.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode; + + /** + * Validates test cases and reports them if found in violation + * @param cases A list of test case nodes + */ + function validateTestCases(cases: TestInfo['valid']): void { + // Gather all of the information from each test case + const namesSeen = new Set(); + const violatingNodes: NonNullable[] = []; + + cases + .filter((testCase) => !!testCase) + .forEach((testCase) => { + if (testCase.type === 'ObjectExpression') { + for (const property of evaluateObjectProperties( + testCase, + sourceCode.scopeManager, + )) { + if (property.type === 'Property') { + const keyName = getKeyName( + property, + sourceCode.getScope(testCase), + ); + if ( + keyName === 'name' && + property.value.type === 'Literal' && + typeof property.value.value === 'string' + ) { + const name = property.value.value; + if (namesSeen.has(name)) { + violatingNodes.push(property.value); + } else { + namesSeen.add(name); + } + break; + } + } + } + } + }); + + for (const node of violatingNodes) { + context.report({ + node, + messageId: 'notUnique', + }); + } + } + + return { + Program(ast) { + getTestInfo(context, ast).forEach((testInfo) => { + validateTestCases(testInfo.valid); + validateTestCases(testInfo.invalid); + }); + }, + }; + }, +}; + +export default rule; diff --git a/tests/lib/rules/unique-test-case-names.ts b/tests/lib/rules/unique-test-case-names.ts new file mode 100644 index 00000000..49aae8dc --- /dev/null +++ b/tests/lib/rules/unique-test-case-names.ts @@ -0,0 +1,83 @@ +import { RuleTester } from 'eslint'; + +import rule from '../../../lib/rules/unique-test-case-names.ts'; + +/** + * Returns the code for some valid test cases + * @param cases The code representation of valid test cases + * @returns Code representing the test cases + */ +function getTestCases(valid: string[], invalid: string[] = []): string { + return ` + new RuleTester().run('foo', bar, { + valid: [ + ${valid.join(',\n ')}, + ], + invalid: [ + ${invalid.join(',\n ')}, + ] + }); + `; +} + +const errorBuffer = 3; // Lines before the test cases start + +const error = (line?: number) => ({ + messageId: 'notUnique', + ...(typeof line === 'number' ? { line } : {}), +}); + +const ruleTester = new RuleTester({ + languageOptions: { sourceType: 'module' }, +}); +ruleTester.run('unique-test-case-names', rule, { + valid: [ + { + code: getTestCases(['"foo"', '"bar"', '"baz"']), + name: 'only shorthand strings', + }, + { + code: getTestCases(['"foo"', '"foo"', '"foo"']), + name: 'redundant shorthand strings', + }, + { + code: getTestCases(['"foo"', '"bar"', '{ code: "foo" }']), + name: 'shorthand strings and object without name', + }, + { + code: getTestCases([ + '{ code: "foo" }', + '{ code: "bar", name: "my test" }', + ]), + name: 'object without name and with name', + }, + { + code: getTestCases([ + '{ code: "foo", name: "my test" }', + '{ code: "bar", name: "my other test" }', + ]), + name: 'object with unique names', + }, + { + code: getTestCases(['foo']), + name: 'non-string, non-object test case (identifier)', + }, + { + code: getTestCases(['foo()']), + name: 'non-string, non-object test case (function)', + }, + ], + + invalid: [ + { + code: getTestCases([ + '{ code: "foo", name: "my test" }', + '{ code: "bar", name: "my other test" }', + '{ code: "baz", name: "my test" }', + '{ code: "bla", name: "my other test" }', + ]), + errors: [error(errorBuffer + 3), error(errorBuffer + 4)], + name: 'object with non-unique names', + }, + ], +});