From 9787ed59fcc930e3d33c8a6efe473da3eca01707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 24 Jan 2024 08:26:59 -0300 Subject: [PATCH] feat: Support for regular expressions in toHaveClass (#563) --- README.md | 11 +++- src/__tests__/to-have-class.js | 63 +++++++++++++++++-- src/to-have-class.js | 24 +++++-- .../jest/jest-custom-expect-types.test.ts | 6 +- types/matchers.d.ts | 3 +- 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 33961efb..913cf195 100644 --- a/README.md +++ b/README.md @@ -753,10 +753,12 @@ toHaveClass(...classNames: string[], options?: {exact: boolean}) ``` This allows you to check whether the given element has certain classes within -its `class` attribute. +its `class` attribute. You must provide at least one class, unless you are +asserting that an element does not have any classes. -You must provide at least one class, unless you are asserting that an element -does not have any classes. +The list of class names may include strings and regular expressions. Regular +expressions are matched against each individual class in the target element, and +it is NOT matched against its full `class` attribute value as whole. #### Examples @@ -773,8 +775,11 @@ const noClasses = getByTestId('no-classes') expect(deleteButton).toHaveClass('extra') expect(deleteButton).toHaveClass('btn-danger btn') +expect(deleteButton).toHaveClass(/danger/, 'btn') expect(deleteButton).toHaveClass('btn-danger', 'btn') expect(deleteButton).not.toHaveClass('btn-link') +expect(deleteButton).not.toHaveClass(/link/) +expect(deleteButton).not.toHaveClass(/btn extra/) // It does not match expect(deleteButton).toHaveClass('btn-danger extra btn', {exact: true}) // to check if the element has EXACTLY a set of classes expect(deleteButton).not.toHaveClass('btn-danger extra', {exact: true}) // if it has more than expected it is going to fail diff --git a/src/__tests__/to-have-class.js b/src/__tests__/to-have-class.js index 85bf8538..abfcf5d9 100644 --- a/src/__tests__/to-have-class.js +++ b/src/__tests__/to-have-class.js @@ -93,6 +93,32 @@ test('.toHaveClass', () => { ).toThrowError(/(none)/) }) +test('.toHaveClass with regular expressions', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(queryByTestId('delete-button')).toHaveClass(/btn/) + expect(queryByTestId('delete-button')).toHaveClass(/danger/) + expect(queryByTestId('delete-button')).toHaveClass( + /-danger$/, + 'extra', + /^btn-[a-z]+$/, + /\bbtn/, + ) + + // It does not match with "btn extra", even though it is a substring of the + // class "btn extra btn-danger". This is because the regular expression is + // matched against each class individually. + expect(queryByTestId('delete-button')).not.toHaveClass(/btn extra/) + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass(/danger/), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(/dangerous/), + ).toThrowError() +}) + test('.toHaveClass with exact mode option', () => { const {queryByTestId} = renderElementWithClasses() @@ -102,9 +128,10 @@ test('.toHaveClass with exact mode option', () => { expect(queryByTestId('delete-button')).not.toHaveClass('btn extra', { exact: true, }) - expect( - queryByTestId('delete-button'), - ).not.toHaveClass('btn extra btn-danger foo', {exact: true}) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: true}, + ) expect(queryByTestId('delete-button')).toHaveClass('btn extra btn-danger', { exact: false, @@ -112,9 +139,10 @@ test('.toHaveClass with exact mode option', () => { expect(queryByTestId('delete-button')).toHaveClass('btn extra', { exact: false, }) - expect( - queryByTestId('delete-button'), - ).not.toHaveClass('btn extra btn-danger foo', {exact: false}) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: false}, + ) expect(queryByTestId('delete-button')).toHaveClass( 'btn', @@ -178,3 +206,26 @@ test('.toHaveClass with exact mode option', () => { }), ).toThrowError(/Expected the element to have EXACTLY defined classes/) }) + +test('.toHaveClass combining {exact:true} and regular expressions throws an error', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass(/btn/, { + exact: true, + }), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass( + /-danger$/, + 'extra', + /\bbtn/, + {exact: true}, + ), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(/danger/, {exact: true}), + ).toThrowError() +}) diff --git a/src/to-have-class.js b/src/to-have-class.js index be59be3a..f59f4dd9 100644 --- a/src/to-have-class.js +++ b/src/to-have-class.js @@ -4,7 +4,7 @@ function getExpectedClassNamesAndOptions(params) { const lastParam = params.pop() let expectedClassNames, options - if (typeof lastParam === 'object') { + if (typeof lastParam === 'object' && !(lastParam instanceof RegExp)) { expectedClassNames = params options = lastParam } else { @@ -15,14 +15,16 @@ function getExpectedClassNamesAndOptions(params) { } function splitClassNames(str) { - if (!str) { - return [] - } + if (!str) return [] return str.split(/\s+/).filter(s => s.length > 0) } function isSubset(subset, superset) { - return subset.every(item => superset.includes(item)) + return subset.every(strOrRegexp => + typeof strOrRegexp === 'string' + ? superset.includes(strOrRegexp) + : superset.some(className => strOrRegexp.test(className)), + ) } export function toHaveClass(htmlElement, ...params) { @@ -31,10 +33,20 @@ export function toHaveClass(htmlElement, ...params) { const received = splitClassNames(htmlElement.getAttribute('class')) const expected = expectedClassNames.reduce( - (acc, className) => acc.concat(splitClassNames(className)), + (acc, className) => + acc.concat( + typeof className === 'string' || !className + ? splitClassNames(className) + : className, + ), [], ) + const hasRegExp = expected.some(className => className instanceof RegExp) + if (options.exact && hasRegExp) { + throw new Error('Exact option does not support RegExp expected class names') + } + if (options.exact) { return { pass: isSubset(expected, received) && expected.length === received.length, diff --git a/types/__tests__/jest/jest-custom-expect-types.test.ts b/types/__tests__/jest/jest-custom-expect-types.test.ts index c3ac5a7d..6b64b8e5 100644 --- a/types/__tests__/jest/jest-custom-expect-types.test.ts +++ b/types/__tests__/jest/jest-custom-expect-types.test.ts @@ -38,7 +38,8 @@ customExpect(element).toHaveAttribute('attr', true) customExpect(element).toHaveAttribute('attr', 'yes') customExpect(element).toHaveClass() customExpect(element).toHaveClass('cls1') -customExpect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +customExpect(element).toHaveClass(/cls/) +customExpect(element).toHaveClass('cls1', 'cls2', /cls(3|4)/) customExpect(element).toHaveClass('cls1', {exact: true}) customExpect(element).toHaveDisplayValue('str') customExpect(element).toHaveDisplayValue(['str1', 'str2']) @@ -94,3 +95,6 @@ customExpect(element).toHaveErrorMessage( // @ts-expect-error The types accidentally allowed any property by falling back to "any" customExpect(element).nonExistentProperty() + +// @ts-expect-error +customExpect(element).toHaveClass(/cls/, {exact: true}) diff --git a/types/matchers.d.ts b/types/matchers.d.ts index d1b13b7a..213f94cd 100755 --- a/types/matchers.d.ts +++ b/types/matchers.d.ts @@ -249,13 +249,14 @@ declare namespace matchers { * const noClasses = getByTestId('no-classes') * expect(deleteButton).toHaveClass('btn') * expect(deleteButton).toHaveClass('btn-danger xs') + * expect(deleteButton).toHaveClass(/danger/, 'xs') * expect(deleteButton).toHaveClass('btn xs btn-danger', {exact: true}) * expect(deleteButton).not.toHaveClass('btn xs btn-danger', {exact: true}) * expect(noClasses).not.toHaveClass() * @see * [testing-library/jest-dom#tohaveclass](https://github.com/testing-library/jest-dom#tohaveclass) */ - toHaveClass(...classNames: string[]): R + toHaveClass(...classNames: Array): R toHaveClass(classNames: string, options?: {exact: boolean}): R /** * @description