diff --git a/docs/rules/valid-title.md b/docs/rules/valid-title.md index 6ab5cfbb0..bb3ab76e8 100644 --- a/docs/rules/valid-title.md +++ b/docs/rules/valid-title.md @@ -152,9 +152,11 @@ describe('foo', () => { ## Options ```ts -interface { +interface Options { ignoreTypeOfDescribeName?: boolean; disallowedWords?: string[]; + mustNotMatch?: Partial> | string; + mustMatch?: Partial> | string; } ``` @@ -172,7 +174,7 @@ Default: `[]` A string array of words that are not allowed to be used in test titles. Matching is not case-sensitive, and looks for complete words: -Examples of **incorrect** code using `disallowedWords`: +Examples of **incorrect** code when using `disallowedWords`: ```js // with disallowedWords: ['correct', 'all', 'every', 'properly'] @@ -190,3 +192,37 @@ it('correctly sets the value', () => {}); test('that everything is as it should be', () => {}); describe('the proper way to handle things', () => {}); ``` + +#### `mustMatch` & `mustNotMatch` + +Defaults: `{}` + +Allows enforcing that titles must match or must not match a given Regular +Expression. An object can be provided to apply different Regular Expressions to +specific Jest test function groups (`describe`, `test`, and `it`). + +Examples of **incorrect** code when using `mustMatch`: + +```js +// with mustMatch: '$that' +describe('the correct way to do things', () => {}); +fit('this there!', () => {}); + +// with mustMatch: { test: '$that' } +describe('the tests that will be run', () => {}); +test('the stuff works', () => {}); +xtest('errors that are thrown have messages', () => {}); +``` + +Examples of **correct** code when using `mustMatch`: + +```js +// with mustMatch: '$that' +describe('that thing that needs to be done', () => {}); +fit('that this there!', () => {}); + +// with mustMatch: { test: '$that' } +describe('the tests that will be run', () => {}); +test('that the stuff works', () => {}); +xtest('that errors that thrown have messages', () => {}); +``` diff --git a/src/rules/__tests__/valid-title.test.ts b/src/rules/__tests__/valid-title.test.ts index 714dc805b..a821dc5d3 100644 --- a/src/rules/__tests__/valid-title.test.ts +++ b/src/rules/__tests__/valid-title.test.ts @@ -1,4 +1,5 @@ import { TSESLint } from '@typescript-eslint/experimental-utils'; +import dedent from 'dedent'; import resolveFrom from 'resolve-from'; import rule from '../valid-title'; @@ -100,6 +101,222 @@ ruleTester.run('disallowedWords option', rule, { ], }); +ruleTester.run('mustMatch & mustNotMatch options', rule, { + valid: [ + 'describe("the correct way to properly handle all the things", () => {});', + 'test("that all is as it should be", () => {});', + { + code: 'it("correctly sets the value", () => {});', + options: [{ mustMatch: undefined }], + }, + { + code: 'it("correctly sets the value", () => {});', + options: [{ mustMatch: / /u.source }], + }, + { + code: 'it("correctly sets the value #unit", () => {});', + options: [{ mustMatch: /#(?:unit|integration|e2e)/u.source }], + }, + { + code: 'it("correctly sets the value", () => {});', + options: [{ mustMatch: /^[^#]+$|(?:#(?:unit|e2e))/u.source }], + }, + { + code: 'it("correctly sets the value", () => {});', + options: [{ mustMatch: { test: /#(?:unit|integration|e2e)/u.source } }], + }, + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e2e', () => { + it('is another test #jest4life', () => {}); + }); + }); + `, + options: [{ mustMatch: { test: /^[^#]+$|(?:#(?:unit|e2e))/u.source } }], + }, + ], + invalid: [ + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #jest4life', () => {}); + }); + }); + `, + options: [ + { + mustNotMatch: /(?:#(?!unit|e2e))\w+/u.source, + mustMatch: /^[^#]+$|(?:#(?:unit|e2e))/u.source, + }, + ], + errors: [ + { + messageId: 'mustNotMatch', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + }, + column: 12, + line: 8, + }, + { + messageId: 'mustNotMatch', + data: { + jestFunctionName: 'it', + pattern: /(?:#(?!unit|e2e))\w+/u, + }, + column: 8, + line: 9, + }, + ], + }, + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #jest4life', () => {}); + }); + }); + `, + options: [ + { + mustNotMatch: { describe: /(?:#(?!unit|e2e))\w+/u.source }, + mustMatch: { describe: /^[^#]+$|(?:#(?:unit|e2e))/u.source }, + }, + ], + errors: [ + { + messageId: 'mustNotMatch', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + }, + column: 12, + line: 8, + }, + ], + }, + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #jest4life', () => {}); + }); + }); + `, + options: [ + { + mustNotMatch: { describe: /(?:#(?!unit|e2e))\w+/u.source }, + mustMatch: { it: /^[^#]+$|(?:#(?:unit|e2e))/u.source }, + }, + ], + errors: [ + { + messageId: 'mustNotMatch', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + }, + column: 12, + line: 8, + }, + ], + }, + { + code: 'test("the correct way to properly handle all things", () => {});', + options: [{ mustMatch: /#(?:unit|integration|e2e)/u.source }], + errors: [ + { + messageId: 'mustMatch', + data: { + jestFunctionName: 'test', + pattern: /#(?:unit|integration|e2e)/u, + }, + column: 6, + line: 1, + }, + ], + }, + { + code: 'describe("the test", () => {});', + options: [ + { mustMatch: { describe: /#(?:unit|integration|e2e)/u.source } }, + ], + errors: [ + { + messageId: 'mustMatch', + data: { + jestFunctionName: 'describe', + pattern: /#(?:unit|integration|e2e)/u, + }, + column: 10, + line: 1, + }, + ], + }, + { + code: 'xdescribe("the test", () => {});', + options: [ + { mustMatch: { describe: /#(?:unit|integration|e2e)/u.source } }, + ], + errors: [ + { + messageId: 'mustMatch', + data: { + jestFunctionName: 'describe', + pattern: /#(?:unit|integration|e2e)/u, + }, + column: 11, + line: 1, + }, + ], + }, + { + code: 'describe.skip("the test", () => {});', + options: [ + { mustMatch: { describe: /#(?:unit|integration|e2e)/u.source } }, + ], + errors: [ + { + messageId: 'mustMatch', + data: { + jestFunctionName: 'describe', + pattern: /#(?:unit|integration|e2e)/u, + }, + column: 15, + line: 1, + }, + ], + }, + ], +}); + ruleTester.run('title-must-be-string', rule, { valid: [ 'it("is a string", () => {});', diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index e05f5aedf..bc9caa771 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -37,17 +37,45 @@ const quoteStringValue = (node: StringNode): string => ? `\`${node.quasis[0].value.raw}\`` : node.raw; +const compileMatcherPatterns = ( + matchers: Partial> | string, +): Record & Record => { + if (typeof matchers === 'string') { + const matcher = new RegExp(matchers, 'u'); + + return { + describe: matcher, + test: matcher, + it: matcher, + }; + } + + return { + describe: matchers.describe ? new RegExp(matchers.describe, 'u') : null, + test: matchers.test ? new RegExp(matchers.test, 'u') : null, + it: matchers.it ? new RegExp(matchers.it, 'u') : null, + }; +}; + +type MatcherGroups = 'describe' | 'test' | 'it'; + +interface Options { + ignoreTypeOfDescribeName?: boolean; + disallowedWords?: string[]; + mustNotMatch?: Partial> | string; + mustMatch?: Partial> | string; +} + type MessageIds = | 'titleMustBeString' | 'emptyTitle' | 'duplicatePrefix' | 'accidentalSpace' - | 'disallowedWord'; + | 'disallowedWord' + | 'mustNotMatch' + | 'mustMatch'; -export default createRule< - [{ ignoreTypeOfDescribeName?: boolean; disallowedWords?: string[] }], - MessageIds ->({ +export default createRule<[Options], MessageIds>({ name: __filename, meta: { docs: { @@ -61,6 +89,8 @@ export default createRule< duplicatePrefix: 'should not have duplicate prefix', accidentalSpace: 'should not have leading or trailing spaces', disallowedWord: '"{{ word }}" is not allowed in test titles.', + mustNotMatch: '{{ jestFunctionName }} should not match {{ pattern }}', + mustMatch: '{{ jestFunctionName }} should match {{ pattern }}', }, type: 'suggestion', schema: [ @@ -75,6 +105,34 @@ export default createRule< type: 'array', items: { type: 'string' }, }, + mustNotMatch: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + describe: { type: 'string' }, + test: { type: 'string' }, + it: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + mustMatch: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + describe: { type: 'string' }, + test: { type: 'string' }, + it: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, }, additionalProperties: false, }, @@ -82,12 +140,25 @@ export default createRule< fixable: 'code', }, defaultOptions: [{ ignoreTypeOfDescribeName: false, disallowedWords: [] }], - create(context, [{ ignoreTypeOfDescribeName, disallowedWords = [] }]) { + create( + context, + [ + { + ignoreTypeOfDescribeName, + disallowedWords = [], + mustNotMatch, + mustMatch, + }, + ], + ) { const disallowedWordsRegexp = new RegExp( `\\b(${disallowedWords.join('|')})\\b`, 'iu', ); + const mustNotMatchPatterns = compileMatcherPatterns(mustNotMatch ?? {}); + const mustMatchPatterns = compileMatcherPatterns(mustMatch ?? {}); + return { CallExpression(node: TSESTree.CallExpression) { if (!isDescribe(node) && !isTestCase(node)) { @@ -181,6 +252,36 @@ export default createRule< ], }); } + + const [jestFunctionName] = nodeName.split('.'); + + const mustNotMatchPattern = mustNotMatchPatterns[jestFunctionName]; + + if (mustNotMatchPattern) { + if (mustNotMatchPattern.test(title)) { + context.report({ + messageId: 'mustNotMatch', + node: argument, + data: { jestFunctionName, pattern: mustNotMatchPattern }, + }); + + return; + } + } + + const mustMatchPattern = mustMatchPatterns[jestFunctionName]; + + if (mustMatchPattern) { + if (!mustMatchPattern.test(title)) { + context.report({ + messageId: 'mustMatch', + node: argument, + data: { jestFunctionName, pattern: mustMatchPattern }, + }); + + return; + } + } }, }; },