From ffc93921348b0d4a394125f665d2bb09148ea37e Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 29 Sep 2021 20:37:21 +1300 Subject: [PATCH] feat(valid-title): allow custom matcher messages (#913) --- docs/rules/valid-title.md | 32 ++++- src/rules/__tests__/valid-title.test.ts | 148 +++++++++++++++++++++++- src/rules/valid-title.ts | 115 +++++++++++------- 3 files changed, 252 insertions(+), 43 deletions(-) diff --git a/docs/rules/valid-title.md b/docs/rules/valid-title.md index a5edda67e..ab1ae76ad 100644 --- a/docs/rules/valid-title.md +++ b/docs/rules/valid-title.md @@ -198,8 +198,9 @@ describe('the proper way to handle things', () => {}); 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`). +Expression, with an optional message. An object can be provided to apply +different Regular Expressions (with optional messages) to specific Jest test +function groups (`describe`, `test`, and `it`). Examples of **incorrect** code when using `mustMatch`: @@ -226,3 +227,30 @@ describe('the tests that will be run', () => {}); test('that the stuff works', () => {}); xtest('that errors that thrown have messages', () => {}); ``` + +Optionally you can provide a custom message to show for a particular matcher by +using a tuple at any level where you can provide a matcher: + +```js +const prefixes = ['when', 'with', 'without', 'if', 'unless', 'for']; +const prefixesList = prefixes.join(' - \n'); + +module.exports = { + rules: { + 'jest/valid-title': [ + 'error', + { + mustNotMatch: ['\\.$', 'Titles should not end with a full-stop'], + mustMatch: { + describe: [ + new RegExp(`^(?:[A-Z]|\\b(${prefixes.join('|')})\\b`, 'u').source, + `Describe titles should either start with a capital letter or one of the following prefixes: ${prefixesList}`, + ], + test: [/[^A-Z]/u.source], + it: /[^A-Z]/u.source, + }, + }, + ], + }, +}; +``` diff --git a/src/rules/__tests__/valid-title.test.ts b/src/rules/__tests__/valid-title.test.ts index c0e30d2b7..68f9013c4 100644 --- a/src/rules/__tests__/valid-title.test.ts +++ b/src/rules/__tests__/valid-title.test.ts @@ -107,12 +107,16 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { 'test("that all is as it should be", () => {});', { code: 'it("correctly sets the value", () => {});', - options: [{ mustMatch: undefined }], + options: [{ mustMatch: {} }], }, { code: 'it("correctly sets the value", () => {});', options: [{ mustMatch: / /u.source }], }, + { + code: 'it("correctly sets the value", () => {});', + options: [{ mustMatch: [/ /u.source] }], + }, { code: 'it("correctly sets the value #unit", () => {});', options: [{ mustMatch: /#(?:unit|integration|e2e)/u.source }], @@ -200,7 +204,56 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { `, options: [ { - mustNotMatch: { describe: /(?:#(?!unit|e2e))\w+/u.source }, + mustNotMatch: [ + /(?:#(?!unit|e2e))\w+/u.source, + 'Please include "#unit" or "#e2e" in titles', + ], + mustMatch: [ + /^[^#]+$|(?:#(?:unit|e2e))/u.source, + 'Please include "#unit" or "#e2e" in titles', + ], + }, + ], + errors: [ + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in titles', + }, + column: 12, + line: 8, + }, + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'it', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in titles', + }, + 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 }, }, ], @@ -230,6 +283,44 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { }); }); `, + options: [ + { + mustNotMatch: { + describe: [ + /(?:#(?!unit|e2e))\w+/u.source, + 'Please include "#unit" or "#e2e" in describe titles', + ], + }, + mustMatch: { describe: /^[^#]+$|(?:#(?:unit|e2e))/u.source }, + }, + ], + errors: [ + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in describe titles', + }, + 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 }, @@ -248,6 +339,59 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { }, ], }, + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true #jest4life', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #jest4life', () => {}); + }); + }); + `, + options: [ + { + mustNotMatch: { + describe: [ + /(?:#(?!unit|e2e))\w+/u.source, + 'Please include "#unit" or "#e2e" in describe titles', + ], + }, + mustMatch: { + it: [ + /^[^#]+$|(?:#(?:unit|e2e))/u.source, + 'Please include "#unit" or "#e2e" in it titles', + ], + }, + }, + ], + errors: [ + { + messageId: 'mustMatchCustom', + data: { + jestFunctionName: 'it', + pattern: /^[^#]+$|(?:#(?:unit|e2e))/u, + message: 'Please include "#unit" or "#e2e" in it titles', + }, + column: 8, + line: 3, + }, + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in describe titles', + }, + column: 12, + line: 8, + }, + ], + }, { code: 'test("the correct way to properly handle all things", () => {});', options: [{ mustMatch: /#(?:unit|integration|e2e)/u.source }], diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 6902f1262..de2f00e77 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -1,5 +1,6 @@ import { AST_NODE_TYPES, + JSONSchema, TSESTree, } from '@typescript-eslint/experimental-utils'; import { @@ -36,33 +37,66 @@ const quoteStringValue = (node: StringNode): string => ? `\`${node.quasis[0].value.raw}\`` : node.raw; +const compileMatcherPattern = ( + matcherMaybeWithMessage: MatcherAndMessage | string, +): CompiledMatcherAndMessage => { + const [matcher, message] = Array.isArray(matcherMaybeWithMessage) + ? matcherMaybeWithMessage + : [matcherMaybeWithMessage]; + + return [new RegExp(matcher, 'u'), message]; +}; + const compileMatcherPatterns = ( - matchers: Partial> | string, -): Record & Record => { - if (typeof matchers === 'string') { - const matcher = new RegExp(matchers, 'u'); + matchers: + | Partial> + | MatcherAndMessage + | string, +): Record & + Record => { + if (typeof matchers === 'string' || Array.isArray(matchers)) { + const compiledMatcher = compileMatcherPattern(matchers); return { - describe: matcher, - test: matcher, - it: matcher, + describe: compiledMatcher, + test: compiledMatcher, + it: compiledMatcher, }; } 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, + describe: matchers.describe + ? compileMatcherPattern(matchers.describe) + : null, + test: matchers.test ? compileMatcherPattern(matchers.test) : null, + it: matchers.it ? compileMatcherPattern(matchers.it) : null, }; }; +type CompiledMatcherAndMessage = [matcher: RegExp, message?: string]; +type MatcherAndMessage = [matcher: string, message?: string]; + +const MatcherAndMessageSchema: JSONSchema.JSONSchema7 = { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, +} as const; + type MatcherGroups = 'describe' | 'test' | 'it'; interface Options { ignoreTypeOfDescribeName?: boolean; disallowedWords?: string[]; - mustNotMatch?: Partial> | string; - mustMatch?: Partial> | string; + mustNotMatch?: + | Partial> + | MatcherAndMessage + | string; + mustMatch?: + | Partial> + | MatcherAndMessage + | string; } type MessageIds = @@ -72,7 +106,9 @@ type MessageIds = | 'accidentalSpace' | 'disallowedWord' | 'mustNotMatch' - | 'mustMatch'; + | 'mustMatch' + | 'mustNotMatchCustom' + | 'mustMatchCustom'; export default createRule<[Options], MessageIds>({ name: __filename, @@ -90,6 +126,8 @@ export default createRule<[Options], MessageIds>({ disallowedWord: '"{{ word }}" is not allowed in test titles.', mustNotMatch: '{{ jestFunctionName }} should not match {{ pattern }}', mustMatch: '{{ jestFunctionName }} should match {{ pattern }}', + mustNotMatchCustom: '{{ message }}', + mustMatchCustom: '{{ message }}', }, type: 'suggestion', schema: [ @@ -104,31 +142,18 @@ export default createRule<[Options], MessageIds>({ type: 'array', items: { type: 'string' }, }, - mustNotMatch: { - oneOf: [ - { type: 'string' }, - { - type: 'object', - properties: { - describe: { type: 'string' }, - test: { type: 'string' }, - it: { type: 'string' }, - }, - additionalProperties: false, - }, - ], - }, - mustMatch: { + }, + patternProperties: { + [/^must(?:Not)?Match$/u.source]: { oneOf: [ { type: 'string' }, + MatcherAndMessageSchema, { type: 'object', - properties: { - describe: { type: 'string' }, - test: { type: 'string' }, - it: { type: 'string' }, + propertyNames: { enum: ['describe', 'test', 'it'] }, + additionalProperties: { + oneOf: [{ type: 'string' }, MatcherAndMessageSchema], }, - additionalProperties: false, }, ], }, @@ -254,28 +279,40 @@ export default createRule<[Options], MessageIds>({ const [jestFunctionName] = nodeName.split('.'); - const mustNotMatchPattern = mustNotMatchPatterns[jestFunctionName]; + const [mustNotMatchPattern, mustNotMatchMessage] = + mustNotMatchPatterns[jestFunctionName] ?? []; if (mustNotMatchPattern) { if (mustNotMatchPattern.test(title)) { context.report({ - messageId: 'mustNotMatch', + messageId: mustNotMatchMessage + ? 'mustNotMatchCustom' + : 'mustNotMatch', node: argument, - data: { jestFunctionName, pattern: mustNotMatchPattern }, + data: { + jestFunctionName, + pattern: mustNotMatchPattern, + message: mustNotMatchMessage, + }, }); return; } } - const mustMatchPattern = mustMatchPatterns[jestFunctionName]; + const [mustMatchPattern, mustMatchMessage] = + mustMatchPatterns[jestFunctionName] ?? []; if (mustMatchPattern) { if (!mustMatchPattern.test(title)) { context.report({ - messageId: 'mustMatch', + messageId: mustMatchMessage ? 'mustMatchCustom' : 'mustMatch', node: argument, - data: { jestFunctionName, pattern: mustMatchPattern }, + data: { + jestFunctionName, + pattern: mustMatchPattern, + message: mustMatchMessage, + }, }); return;