From 4b764b9f6a7b564d7f8ec0e9b0c6ba9cc875f2b8 Mon Sep 17 00:00:00 2001 From: John Gozde Date: Sun, 13 Aug 2023 10:28:15 -0600 Subject: [PATCH] feat: local types, supporting jest, @jest/globals, vitest (#511) * feat!: local types, supporting jest, @jest/globals, vitest Moves matcher types back into this package and adds support for @jest/globals and vitest. BREAKING CHANGE: Removes the extend-expect script. Users should use the default import path or one of the new test platform-specific paths to automatically extend the appropriate "expect" instance. extend-expect was not documented in the Readme, so this change should have minimal impact. Users can now use the following import paths to automatically extend "expect" for their chosen test platform: - @testing-library/jest-dom - jest (@types/jest) - @testing-library/jest-dom/jest-globals - @jest/globals - @testing-library/jest-dom/vitest - vitest For example: import '@testing-library/jest-dom/jest-globals' Importing from one of the above paths will augment the appropriate matcher interface for the given test platform, assuming the import is done in a .ts file that is included in the user's tsconfig.json. It's also (still) possible to import the matchers directly without side effects: import * as matchers from '@testing-library/jest-dom/matchers' * Update kcd-scripts BREAKING CHANGE: Drop node < 14 --- .github/workflows/validate.yml | 2 +- README.md | 48 +++ extend-expect.js | 2 - jest-globals.d.ts | 1 + jest-globals.js | 4 + matchers.d.ts | 3 + package.json | 57 ++- src/extend-expect.js | 3 - src/index.js | 4 +- src/to-be-in-the-document.js | 2 +- src/to-contain-element.js | 2 +- src/to-contain-html.js | 2 +- src/utils.js | 10 +- tests/setup-env.js | 2 +- tsconfig.json | 7 + types/index.d.ts | 1 + types/jest-globals.d.ts | 8 + types/jest.d.ts | 10 + types/matchers.d.ts | 666 +++++++++++++++++++++++++++++++++ types/vitest.d.ts | 8 + vitest.d.ts | 1 + vitest.js | 4 + 22 files changed, 824 insertions(+), 23 deletions(-) delete mode 100644 extend-expect.js create mode 100644 jest-globals.d.ts create mode 100644 jest-globals.js create mode 100644 matchers.d.ts delete mode 100644 src/extend-expect.js create mode 100644 tsconfig.json create mode 100644 types/index.d.ts create mode 100644 types/jest-globals.d.ts create mode 100644 types/jest.d.ts create mode 100755 types/matchers.d.ts create mode 100644 types/vitest.d.ts create mode 100644 vitest.d.ts create mode 100644 vitest.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e6b270ac..6a727c41 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,7 +16,7 @@ jobs: if: ${{ !contains(github.head_ref, 'all-contributors') }} strategy: matrix: - node: [10.14, 12, 14, 15, 16] + node: [14, 16, 18, 20] runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo diff --git a/README.md b/README.md index 7c6d3c65..4f0961d9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,10 @@ clear to read and to maintain. - [Installation](#installation) - [Usage](#usage) + - [With `@jest/globals`](#with-jestglobals) + - [With Vitest](#with-vitest) - [With TypeScript](#with-typescript) + - [With another Jest-compatible `expect`](#with-another-jest-compatible-expect) - [Custom matchers](#custom-matchers) - [`toBeDisabled`](#tobedisabled) - [`toBeEnabled`](#tobeenabled) @@ -128,6 +131,39 @@ import '@testing-library/jest-dom' setupFilesAfterEnv: ['/jest-setup.js'] ``` +### With `@jest/globals` + +If you are using [`@jest/globals`][jest-globals announcement] with +[`injectGlobals: false`][inject-globals docs], you will need to use a different +import in your tests setup file: + +```javascript +// In your own jest-setup.js (or any other name) +import '@testing-library/jest-dom/jest-globals' +``` + +[jest-globals announcement]: + https://jestjs.io/blog/2020/05/05/jest-26#a-new-way-to-consume-jest---jestglobals +[inject-globals docs]: + https://jestjs.io/docs/configuration#injectglobals-boolean + +### With Vitest + +If you are using [vitest][], this module will work as-is, but you will need to +use a different import in your tests setup file. This file should be added to +the [`setupFiles`][vitest setupfiles] property in your vitest config: + +```javascript +// In your own vitest-setup.js (or any other name) +import '@testing-library/jest-dom/vitest' + +// In vitest.config.js add (if you haven't already) +setupFiles: ['./vitest-setup.js'] +``` + +[vitest]: https://vitest.dev/ +[vitest setupfiles]: https://vitest.dev/config/#setupfiles + ### With TypeScript If you're using TypeScript, make sure your setup file is a `.ts` and not a `.js` @@ -144,6 +180,18 @@ haven't already: ], ``` +### With another Jest-compatible `expect` + +If you are using a different test runner that is compatible with Jest's `expect` +interface, it might be possible to use it with this library: + +```javascript +import * as matchers from '@testing-library/jest-dom/matchers' +import {expect} from 'my-test-runner/expect' + +expect.extend(matchers) +``` + ## Custom matchers `@testing-library/jest-dom` can work with any library or framework that returns diff --git a/extend-expect.js b/extend-expect.js deleted file mode 100644 index e7d19c10..00000000 --- a/extend-expect.js +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line -require('./dist/extend-expect') diff --git a/jest-globals.d.ts b/jest-globals.d.ts new file mode 100644 index 00000000..6c8e0b60 --- /dev/null +++ b/jest-globals.d.ts @@ -0,0 +1 @@ +/// diff --git a/jest-globals.js b/jest-globals.js new file mode 100644 index 00000000..eeaf6ac9 --- /dev/null +++ b/jest-globals.js @@ -0,0 +1,4 @@ +const globals = require('@jest/globals') +const extensions = require('./dist/matchers') + +globals.expect.extend(extensions) diff --git a/matchers.d.ts b/matchers.d.ts new file mode 100644 index 00000000..c1ec8ce5 --- /dev/null +++ b/matchers.d.ts @@ -0,0 +1,3 @@ +import * as matchers from './types/matchers' + +export = matchers diff --git a/package.json b/package.json index 0304d2c1..f8346ee5 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "version": "0.0.0-semantically-released", "description": "Custom jest matchers to test the state of the DOM", "main": "dist/index.js", + "types": "types/index.d.ts", "engines": { - "node": ">=8", + "node": ">=14", "npm": ">=6", "yarn": ">=1" }, @@ -19,8 +20,11 @@ }, "files": [ "dist", - "extend-expect.js", - "matchers.js" + "types", + "*.d.ts", + "jest-globals.js", + "matchers.js", + "vitest.js" ], "keywords": [ "testing", @@ -32,7 +36,6 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", "aria-query": "^5.0.0", "chalk": "^3.0.0", "@adobe/css-tools": "^4.0.1", @@ -42,16 +45,44 @@ "redent": "^3.0.0" }, "devDependencies": { + "@jest/globals": "^29.6.2", + "expect": "^29.6.2", "jest-environment-jsdom-sixteen": "^1.0.3", "jest-watch-select-projects": "^2.0.0", "jsdom": "^16.2.1", - "kcd-scripts": "^11.1.0", - "pretty-format": "^25.1.0" + "kcd-scripts": "^14.0.0", + "pretty-format": "^25.1.0", + "vitest": "^0.34.1", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "@jest/globals": ">= 28", + "@types/jest": ">= 28", + "jest": ">= 28", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "@jest/globals": { + "optional": true + }, + "@types/jest": { + "optional": true + }, + "jest": { + "optional": true + }, + "vitest": { + "optional": true + } }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", + "parserOptions": { + "sourceType": "module", + "ecmaVersion": 2020 + }, "rules": { - "@babel/no-invalid-this": "off" + "no-invalid-this": "off" }, "overrides": [ { @@ -61,6 +92,18 @@ "rules": { "max-lines-per-function": "off" } + }, + { + "files": [ + "**/*.d.ts" + ], + "rules": { + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-invalid-void-type": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/triple-slash-reference": "off" + } } ] }, diff --git a/src/extend-expect.js b/src/extend-expect.js deleted file mode 100644 index 3801a1d5..00000000 --- a/src/extend-expect.js +++ /dev/null @@ -1,3 +0,0 @@ -import * as extensions from './matchers' - -expect.extend(extensions) diff --git a/src/index.js b/src/index.js index 8cecbe35..3801a1d5 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,3 @@ -import './extend-expect' +import * as extensions from './matchers' + +expect.extend(extensions) diff --git a/src/to-be-in-the-document.js b/src/to-be-in-the-document.js index 8ccc451a..a7eda78e 100644 --- a/src/to-be-in-the-document.js +++ b/src/to-be-in-the-document.js @@ -29,7 +29,7 @@ export function toBeInTheDocument(element) { '', ), '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap this.utils.RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()), ].join('\n') }, diff --git a/src/to-contain-element.js b/src/to-contain-element.js index c94ddbf9..445a6120 100644 --- a/src/to-contain-element.js +++ b/src/to-contain-element.js @@ -17,7 +17,7 @@ export function toContainElement(container, element) { 'element', ), '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap this.utils.RECEIVED_COLOR(`${this.utils.stringify( container.cloneNode(false), )} ${ diff --git a/src/to-contain-html.js b/src/to-contain-html.js index ccbff5f5..30158ee1 100644 --- a/src/to-contain-html.js +++ b/src/to-contain-html.js @@ -23,7 +23,7 @@ export function toContainHTML(container, htmlText) { '', ), 'Expected:', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap ` ${this.utils.EXPECTED_COLOR(htmlText)}`, 'Received:', ` ${this.utils.printReceived(container.cloneNode(true))}`, diff --git a/src/utils.js b/src/utils.js index cdbb1088..2b45be02 100644 --- a/src/utils.js +++ b/src/utils.js @@ -28,7 +28,7 @@ class GenericTypeError extends Error { '', ), '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap `${context.utils.RECEIVED_COLOR( 'received', )} value must ${expectedString}.`, @@ -91,9 +91,9 @@ class InvalidCSSError extends Error { this.message = [ received.message, '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap context.utils.RECEIVED_COLOR(`Failing css:`), - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap context.utils.RECEIVED_COLOR(`${received.css}`), ].join('\n') } @@ -137,11 +137,11 @@ function getMessage( ) { return [ `${matcher}\n`, - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap `${expectedLabel}:\n${context.utils.EXPECTED_COLOR( redent(display(context, expectedValue), 2), )}`, - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap `${receivedLabel}:\n${context.utils.RECEIVED_COLOR( redent(display(context, receivedValue), 2), )}`, diff --git a/tests/setup-env.js b/tests/setup-env.js index a9325d25..151f6e7b 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1,4 +1,4 @@ import {plugins} from 'pretty-format' -import '../src/extend-expect' +import '../src/index' expect.addSnapshotSerializer(plugins.ConvertAnsi) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..9a426aba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true + }, + "include": ["*.d.ts", "types"] +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 00000000..cebdd1af --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1 @@ +/// diff --git a/types/jest-globals.d.ts b/types/jest-globals.d.ts new file mode 100644 index 00000000..f7de8014 --- /dev/null +++ b/types/jest-globals.d.ts @@ -0,0 +1,8 @@ +import {type expect} from '@jest/globals' +import {type TestingLibraryMatchers} from './matchers' + +export {} +declare module '@jest/expect' { + export interface Matchers> + extends TestingLibraryMatchers {} +} diff --git a/types/jest.d.ts b/types/jest.d.ts new file mode 100644 index 00000000..bca4339c --- /dev/null +++ b/types/jest.d.ts @@ -0,0 +1,10 @@ +/// + +import {type TestingLibraryMatchers} from './matchers' + +declare global { + namespace jest { + interface Matchers + extends TestingLibraryMatchers {} + } +} diff --git a/types/matchers.d.ts b/types/matchers.d.ts new file mode 100755 index 00000000..af43570d --- /dev/null +++ b/types/matchers.d.ts @@ -0,0 +1,666 @@ +declare namespace matchers { + interface TestingLibraryMatchers extends Record { + /** + * @deprecated + * since v1.9.0 + * @description + * Assert whether a value is a DOM element, or not. Contrary to what its name implies, this matcher only checks + * that you passed to it a valid DOM element. + * + * It does not have a clear definition of what "the DOM" is. Therefore, it does not check whether that element + * is contained anywhere. + * @see + * [testing-library/jest-dom#toBeInTheDom](https://github.com/testing-library/jest-dom#toBeInTheDom) + */ + toBeInTheDOM(container?: HTMLElement | SVGElement): R + /** + * @description + * Assert whether an element is present in the document or not. + * @example + * + * + * expect(queryByTestId('svg-element')).toBeInTheDocument() + * expect(queryByTestId('does-not-exist')).not.toBeInTheDocument() + * @see + * [testing-library/jest-dom#tobeinthedocument](https://github.com/testing-library/jest-dom#tobeinthedocument) + */ + toBeInTheDocument(): R + /** + * @description + * This allows you to check if an element is currently visible to the user. + * + * An element is visible if **all** the following conditions are met: + * * it does not have its css property display set to none + * * it does not have its css property visibility set to either hidden or collapse + * * it does not have its css property opacity set to 0 + * * its parent element is also visible (and so on up to the top of the DOM tree) + * * it does not have the hidden attribute + * * if `
` it has the open attribute + * @example + *
+ * Zero Opacity + *
+ * + *
Visible Example
+ * + * expect(getByTestId('zero-opacity')).not.toBeVisible() + * expect(getByTestId('visible')).toBeVisible() + * @see + * [testing-library/jest-dom#tobevisible](https://github.com/testing-library/jest-dom#tobevisible) + */ + toBeVisible(): R + /** + * @deprecated + * since v5.9.0 + * @description + * Assert whether an element has content or not. + * @example + * + * + * + * + * expect(getByTestId('empty')).toBeEmpty() + * expect(getByTestId('not-empty')).not.toBeEmpty() + * @see + * [testing-library/jest-dom#tobeempty](https://github.com/testing-library/jest-dom#tobeempty) + */ + toBeEmpty(): R + /** + * @description + * Assert whether an element has content or not. + * @example + * + * + * + * + * expect(getByTestId('empty')).toBeEmptyDOMElement() + * expect(getByTestId('not-empty')).not.toBeEmptyDOMElement() + * @see + * [testing-library/jest-dom#tobeemptydomelement](https://github.com/testing-library/jest-dom#tobeemptydomelement) + */ + toBeEmptyDOMElement(): R + /** + * @description + * Allows you to check whether an element is disabled from the user's perspective. + * + * Matches if the element is a form control and the `disabled` attribute is specified on this element or the + * element is a descendant of a form element with a `disabled` attribute. + * @example + * + * + * expect(getByTestId('button')).toBeDisabled() + * @see + * [testing-library/jest-dom#tobedisabled](https://github.com/testing-library/jest-dom#tobedisabled) + */ + toBeDisabled(): R + /** + * @description + * Allows you to check whether an element is not disabled from the user's perspective. + * + * Works like `not.toBeDisabled()`. + * + * Use this matcher to avoid double negation in your tests. + * @example + * + * + * expect(getByTestId('button')).toBeEnabled() + * @see + * [testing-library/jest-dom#tobeenabled](https://github.com/testing-library/jest-dom#tobeenabled) + */ + toBeEnabled(): R + /** + * @description + * Check if a form element, or the entire `form`, is currently invalid. + * + * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no + * value or a value of "true", or if the result of `checkValidity()` is false. + * @example + * + * + *
+ * + *
+ * + * expect(getByTestId('no-aria-invalid')).not.toBeInvalid() + * expect(getByTestId('invalid-form')).toBeInvalid() + * @see + * [testing-library/jest-dom#tobeinvalid](https://github.com/testing-library/jest-dom#tobeinvalid) + */ + toBeInvalid(): R + /** + * @description + * This allows you to check if a form element is currently required. + * + * An element is required if it is having a `required` or `aria-required="true"` attribute. + * @example + * + *
+ * + * expect(getByTestId('required-input')).toBeRequired() + * expect(getByTestId('supported-role')).not.toBeRequired() + * @see + * [testing-library/jest-dom#toberequired](https://github.com/testing-library/jest-dom#toberequired) + */ + toBeRequired(): R + /** + * @description + * Allows you to check if a form element is currently required. + * + * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no + * value or a value of "false", or if the result of `checkValidity()` is true. + * @example + * + * + *
+ * + *
+ * + * expect(getByTestId('no-aria-invalid')).not.toBeValid() + * expect(getByTestId('invalid-form')).toBeInvalid() + * @see + * [testing-library/jest-dom#tobevalid](https://github.com/testing-library/jest-dom#tobevalid) + */ + toBeValid(): R + /** + * @description + * Allows you to assert whether an element contains another element as a descendant or not. + * @example + * + * + * + * + * const ancestor = getByTestId('ancestor') + * const descendant = getByTestId('descendant') + * const nonExistantElement = getByTestId('does-not-exist') + * expect(ancestor).toContainElement(descendant) + * expect(descendant).not.toContainElement(ancestor) + * expect(ancestor).not.toContainElement(nonExistantElement) + * @see + * [testing-library/jest-dom#tocontainelement](https://github.com/testing-library/jest-dom#tocontainelement) + */ + toContainElement(element: HTMLElement | SVGElement | null): R + /** + * @description + * Assert whether a string representing a HTML element is contained in another element. + * @example + * + * + * expect(getByTestId('parent')).toContainHTML('') + * @see + * [testing-library/jest-dom#tocontainhtml](https://github.com/testing-library/jest-dom#tocontainhtml) + */ + toContainHTML(htmlText: string): R + /** + * @description + * Allows you to check if a given element has an attribute or not. + * + * You can also optionally check that the attribute has a specific expected value or partial match using + * [expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring) or + * [expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). + * @example + * + * + * expect(button).toHaveAttribute('disabled') + * expect(button).toHaveAttribute('type', 'submit') + * expect(button).not.toHaveAttribute('type', 'button') + * @see + * [testing-library/jest-dom#tohaveattribute](https://github.com/testing-library/jest-dom#tohaveattribute) + */ + toHaveAttribute(attr: string, value?: unknown): R + /** + * @description + * Check whether the given element has certain classes within its `class` attribute. + * + * You must provide at least one class, unless you are asserting that an element does not have any classes. + * @example + * + * + *
no classes
+ * + * const deleteButton = getByTestId('delete-button') + * const noClasses = getByTestId('no-classes') + * expect(deleteButton).toHaveClass('btn') + * expect(deleteButton).toHaveClass('btn-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: string, options?: {exact: boolean}): R + /** + * @description + * This allows you to check whether the given form element has the specified displayed value (the one the + * end user will see). It accepts , + * + * + * + * + * + * + * + * const input = screen.getByLabelText('First name') + * const textarea = screen.getByLabelText('Description') + * const selectSingle = screen.getByLabelText('Fruit') + * const selectMultiple = screen.getByLabelText('Fruits') + * + * expect(input).toHaveDisplayValue('Luca') + * expect(textarea).toHaveDisplayValue('An example description here.') + * expect(selectSingle).toHaveDisplayValue('Select a fruit...') + * expect(selectMultiple).toHaveDisplayValue(['Banana', 'Avocado']) + * + * @see + * [testing-library/jest-dom#tohavedisplayvalue](https://github.com/testing-library/jest-dom#tohavedisplayvalue) + */ + toHaveDisplayValue(value: string | RegExp | Array): R + /** + * @description + * Assert whether an element has focus or not. + * @example + *
+ * + *
+ * + * const input = getByTestId('element-to-focus') + * input.focus() + * expect(input).toHaveFocus() + * input.blur() + * expect(input).not.toHaveFocus() + * @see + * [testing-library/jest-dom#tohavefocus](https://github.com/testing-library/jest-dom#tohavefocus) + */ + toHaveFocus(): R + /** + * @description + * Check if a form or fieldset contains form controls for each given name, and having the specified value. + * + * Can only be invoked on a form or fieldset element. + * @example + *
+ * + * + * + * + *
+ * + * expect(getByTestId('login-form')).toHaveFormValues({ + * username: 'jane.doe', + * rememberMe: true, + * }) + * @see + * [testing-library/jest-dom#tohaveformvalues](https://github.com/testing-library/jest-dom#tohaveformvalues) + */ + toHaveFormValues(expectedValues: Record): R + /** + * @description + * Check if an element has specific css properties with specific values applied. + * + * Only matches if the element has *all* the expected properties applied, not just some of them. + * @example + * + * + * const button = getByTestId('submit-button') + * expect(button).toHaveStyle('background-color: green') + * expect(button).toHaveStyle({ + * 'background-color': 'green', + * display: 'none' + * }) + * @see + * [testing-library/jest-dom#tohavestyle](https://github.com/testing-library/jest-dom#tohavestyle) + */ + toHaveStyle(css: string | Record): R + /** + * @description + * Check whether the given element has a text content or not. + * + * When a string argument is passed through, it will perform a partial case-sensitive match to the element + * content. + * + * To perform a case-insensitive match, you can use a RegExp with the `/i` modifier. + * + * If you want to match the whole content, you can use a RegExp to do it. + * @example + * Text Content + * + * const element = getByTestId('text-content') + * expect(element).toHaveTextContent('Content') + * // to match the whole content + * expect(element).toHaveTextContent(/^Text Content$/) + * // to use case-insentive match + * expect(element).toHaveTextContent(/content$/i) + * expect(element).not.toHaveTextContent('content') + * @see + * [testing-library/jest-dom#tohavetextcontent](https://github.com/testing-library/jest-dom#tohavetextcontent) + */ + toHaveTextContent( + text: string | RegExp, + options?: {normalizeWhitespace: boolean}, + ): R + /** + * @description + * Check whether the given form element has the specified value. + * + * Accepts ``, `