From 57344556e81d98cd9828d142734df817ab75059a Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Tue, 22 Aug 2023 09:28:26 -0700 Subject: [PATCH] feat: add `expect().toThrowInConnectedCallback` matcher (#210) Co-authored-by: Ravi Jayaramappa --- packages/@lwc/jest-preset/README.md | 70 +++++++++++++ packages/@lwc/jest-preset/package.json | 4 +- .../expect-throw-in-connected-callback.js | 92 ++++++++++++++++++ packages/@lwc/jest-preset/src/setup.js | 2 + packages/@lwc/jest-preset/types/index.d.ts | 55 +++++++++++ .../smoke/matchers/__tests__/matchers.test.js | 97 +++++++++++++++++++ test/src/modules/smoke/matchers/matchers.js | 12 +++ 7 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 packages/@lwc/jest-preset/src/matchers/expect-throw-in-connected-callback.js create mode 100644 packages/@lwc/jest-preset/types/index.d.ts create mode 100644 test/src/modules/smoke/matchers/__tests__/matchers.test.js create mode 100644 test/src/modules/smoke/matchers/matchers.js diff --git a/packages/@lwc/jest-preset/README.md b/packages/@lwc/jest-preset/README.md index 0a64e538..fa27252e 100644 --- a/packages/@lwc/jest-preset/README.md +++ b/packages/@lwc/jest-preset/README.md @@ -109,3 +109,73 @@ Create a `__tests__` inside the bundle of the LWC component under test. Then, create a new test file in `__tests__` that follows the naming convention `.test.js` for DOM tests and `.ssr-test.js` for ssr tests. See an example in this projects `src/test` directory. Now you can write and run the Jest tests! + +### Custom matchers + +This package contains convenience functions to help test web components, including Lightning Web Components. + +Note that, for these matchers to work properly in TypeScript, you must import this package from your `*.spec.ts` files: + +```js +import '@lwc/jest-preset'; +``` + +#### expect().toThrowInConnectedCallback + +Allows you to test for an error thrown by the `connectedCallback` of a web component. `connectedCallback` [does not necessarily throw errors synchronously](https://github.com/salesforce/lwc/pull/3662), so this utility makes it easier to test for `connectedCallback` errors. + +##### Example + +```js +// Component +export default class Throws extends LightningElement { + connectedCallback() { + throw new Error('whee!'); + } +} +``` + +```js +// Test +import { createElement } from 'lwc'; + +it('Should throw in connectedCallback', () => { + const element = createElement('x-throws', { is: Throws }); + expect(() => { + document.body.appendChild(element); + }).toThrowErrorInConnectedCallback(/whee!/); +}); +``` + +##### Error matching + +The argument passed in to `toThrowInConnectedCallback` behaves the same as for [Jest's built-in `toThrow`](https://jestjs.io/docs/expect#tothrowerror): + +- Regular expression: error message matches the pattern. +- String: error message includes the substring. +- Error object: error message is equal to the message property of the object. +- Error class: error object is instance of class. + +##### Best practices + +Note that, to avoid false positives, you should try to include _only_ the `document.body.appendChild` call inside of your callback; otherwise you could get a false positive: + +```js +expect(() => { + document.body.appendChild(elm); + throw new Error('false positive!'); +}).toThrowInConnectedCallback(); +``` + +The above Error will be successfully caught by `toThrowInConnectedCallback`, even though it doesn't really occur in the `connectedCallback`. + +##### Web component support + +This matcher works both with LWC components and with non-LWC custom elements that use standard +`connectedCallback` semantics (e.g. [Lit](https://lit.dev/) or [vanilla](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements)). + +It also works with LWC components regardless of whether they use the standard `connectedCallback` or the legacy [synthetic lifecycle](https://github.com/salesforce/lwc/issues/3198) `connectedCallback`. + +#### expect().toThrowErrorInConnectedCallback + +Equivalent to `toThrowInConnectedCallback`. diff --git a/packages/@lwc/jest-preset/package.json b/packages/@lwc/jest-preset/package.json index b68a9904..afa2b013 100644 --- a/packages/@lwc/jest-preset/package.json +++ b/packages/@lwc/jest-preset/package.json @@ -13,13 +13,15 @@ }, "license": "MIT", "main": "jest-preset.js", + "types": "types/index.d.ts", "keywords": [ "jest", "lwc" ], "files": [ "/ssr/*.js", - "/src/**/*.js" + "/src/**/*.js", + "/types" ], "peerDependencies": { "@lwc/compiler": ">=2.48.0", diff --git a/packages/@lwc/jest-preset/src/matchers/expect-throw-in-connected-callback.js b/packages/@lwc/jest-preset/src/matchers/expect-throw-in-connected-callback.js new file mode 100644 index 00000000..1cff01d0 --- /dev/null +++ b/packages/@lwc/jest-preset/src/matchers/expect-throw-in-connected-callback.js @@ -0,0 +1,92 @@ +// Based on Jest's toThrow() https://jestjs.io/docs/expect#tothrowerror +// regular expression: error message matches the pattern +// string: error message includes the substring +// error object: error message is equal to the message property of the object +// error class: error object is instance of class +function matches(matcher, error) { + if (!matcher) { + return true; // any error will do + } + if (typeof matcher === 'string') { + return error.message.includes(matcher); + } + if (matcher instanceof RegExp) { + return matcher.test(error.message); + } + if (typeof matcher === 'function') { + // Error class + return error instanceof matcher; + } + if (matcher.message) { + // Error object + return error.message === matcher.message; + } +} + +// Custom matcher to test for errors in a connectedCallback of a web component, +// either LWC or third-party. +// Due to native vs synthetic lifecycle (https://github.com/salesforce/lwc/pull/3662), +// the connectedCallback function may either throw a synchronous error or an async error +// on `window.onerror`. This function is agnostic to both and can be used as a drop-in replacement +// for `toThrow()`/`toThrowError`. +function createMatcher(matcherName) { + return { + [matcherName]: function toThrowErrorInConnectedCallback(func, matcher) { + if (typeof func !== 'function') { + throw new Error(`Expected a function, received: {func}`); + } + + // There are two cases here: + // 1) Error is thrown by LWC component with synthetic behavior (sync) + // 2) Error is thrown by third-party component or LWC with native behavior (async) + // We don't care which one we get; they are both treated the same. + let syncError; + let asyncError; + + const listener = (errorEvent) => { + errorEvent.preventDefault(); // do not continue bubbling the error; we're handling it + asyncError = errorEvent.error; + }; + window.addEventListener('error', listener); + try { + func(); + } catch (err) { + syncError = err; + } finally { + window.removeEventListener('error', listener); + } + + const { utils } = this; + const overallError = syncError || asyncError; + const pass = !!overallError && matches(matcher, overallError); + + // Inspired by https://github.com/jest-community/jest-extended/blob/1f91c09/src/matchers/toThrowWithMessage.js#L25 + // This shows a nicely-formatted error message to the user + const positiveHint = utils.matcherHint(`.${matcherName}`, 'function', 'expected'); + const negativeHint = utils.matcherHint(`.not.${matcherName}`, 'function', 'expected'); + const message = + `${pass ? positiveHint : negativeHint}` + + '\n\n' + + `Expected connectedCallback${pass ? ' not' : ''} to throw:\n` + + ` ${ + matcher ? utils.printExpected(matcher) : utils.EXPECTED_COLOR('Any error') + }\n` + + 'Thrown:\n' + + ` ${ + overallError + ? utils.printReceived(overallError) + : utils.RECEIVED_COLOR('No error') + }\n`; + + return { + pass, + message: () => message, + }; + }, + }; +} +expect.extend({ + // both names are accepted, ala Jest's `toThrow` and `toThrowError` being equivalent + ...createMatcher('toThrowInConnectedCallback'), + ...createMatcher('toThrowErrorInConnectedCallback'), +}); diff --git a/packages/@lwc/jest-preset/src/setup.js b/packages/@lwc/jest-preset/src/setup.js index facab34a..f8417a12 100644 --- a/packages/@lwc/jest-preset/src/setup.js +++ b/packages/@lwc/jest-preset/src/setup.js @@ -167,3 +167,5 @@ function installRegisterDecoratorsTrap(lwc) { const lwc = require('@lwc/engine-dom'); installRegisterDecoratorsTrap(lwc); + +require('./matchers/expect-throw-in-connected-callback'); diff --git a/packages/@lwc/jest-preset/types/index.d.ts b/packages/@lwc/jest-preset/types/index.d.ts new file mode 100644 index 00000000..a7bc405d --- /dev/null +++ b/packages/@lwc/jest-preset/types/index.d.ts @@ -0,0 +1,55 @@ +/* eslint-disable */ +// Inspired by: +// - https://unpkg.com/browse/expect@29.6.2/build/index.d.ts +// - https://unpkg.com/browse/@testing-library/jest-dom@6.0.1/types/matchers.d.ts +import { type expect } from '@jest/globals'; + +// These types come from @types/jest +// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/bd28a85/types/jest/index.d.ts#L1154-L1161 +interface Constructable { + new (...args: any[]): any; +} + +interface LwcJestMatchers { + /** + * @description + * Assert that appending a custom element to the DOM causes an error to be thrown from that element's `connectedCallback`. + * @example + * expect(() => { + * document.body.appendChild(element); + * }).toThrowInConnectedCallback('expected error message'); + * @see + * [@lwc/jest-preset docs](https://github.com/salesforce/lwc-test/blob/master/packages/%40lwc/jest-preset/README.md#custom-matchers) + */ + // These types come from @types/jest, see above + toThrowInConnectedCallback(expected?: string | Constructable | RegExp | Error): R; + + /** + * @description + * Assert that appending a custom element to the DOM causes an error to be thrown from that element's `connectedCallback`. + * + * Equivalent to `toThrowInConnectedCallback`. + * @example + * expect(() => { + * document.body.appendChild(element); + * }).toThrowErrorInConnectedCallback('expected error message'); + * @see + * [@lwc/jest-preset docs](https://github.com/salesforce/lwc-test/blob/master/packages/%40lwc/jest-preset/README.md#custom-matchers) + */ + // These types come from @types/jest, see above + toThrowErrorInConnectedCallback(expected?: string | Constructable | RegExp | Error): R; +} + +declare global { + namespace jest { + interface Matchers + extends LwcJestMatchers, R> {} + } +} + +declare module '@jest/expect' { + export interface Matchers> + extends LwcJestMatchers, R> {} +} + +export {}; diff --git a/test/src/modules/smoke/matchers/__tests__/matchers.test.js b/test/src/modules/smoke/matchers/__tests__/matchers.test.js new file mode 100644 index 00000000..ba714d12 --- /dev/null +++ b/test/src/modules/smoke/matchers/__tests__/matchers.test.js @@ -0,0 +1,97 @@ +import { createElement } from 'lwc'; +import Matchers from '../matchers'; + +class ThirdParty extends HTMLElement { + throwInConnectedCallback; + + connectedCallback() { + if (this.throwInConnectedCallback) { + throw new ReferenceError('This is an error thrown from connectedCallback'); + } + } +} + +customElements.define('third-party', ThirdParty); + +describe('matchers', () => { + [false, true].forEach((isThirdParty) => { + describe(`isThirdParty=${isThirdParty}`, () => { + const doCreateElement = () => { + return isThirdParty + ? new ThirdParty() + : createElement('smoke-matchers', { + is: Matchers, + }); + }; + [false, true].forEach((shouldThrow) => { + describe(`shouldThrow=${shouldThrow}`, () => { + let elm; + let callback; + + beforeEach(() => { + elm = doCreateElement(); + elm.throwInConnectedCallback = shouldThrow; + callback = () => { + document.body.appendChild(elm); + }; + }); + + [ + { argsType: 'no args', args: [] }, + { argsType: 'string', args: ['error thrown from connectedCallback'] }, + { argsType: 'regex', args: [/error thrown from connectedCallback/] }, + { argsType: 'error class', args: [ReferenceError] }, + { + argsType: 'error object', + args: [new Error('This is an error thrown from connectedCallback')], + }, + ].forEach(({ argsType, args }) => { + describe(`args=${argsType}`, () => { + // happy path - error is thrown and we expect it, or not thrown and we expect that + it('test passes', () => { + if (shouldThrow) { + expect(callback).toThrowInConnectedCallback(...args); + } else { + expect(callback).not.toThrowInConnectedCallback(...args); + } + }); + + // inverse of above - error and we don't expect it, or vice-versa + it('test fails', () => { + expect(() => { + if (shouldThrow) { + expect(callback).not.toThrowInConnectedCallback(...args); + } else { + expect(callback).toThrowInConnectedCallback(...args); + } + }).toThrow(/Expected connectedCallback/); + }); + }); + }); + + // error thrown and we expect an error, but the matcher fails + describe('test fails due to matcher failing', () => { + [ + { argsType: 'string', args: ['yolo'] }, + { argsType: 'regex', args: [/hahaha/] }, + { argsType: 'error class', args: [TypeError] }, + { argsType: 'error object', args: [new Error('lalala')] }, + ].forEach(({ argsType, args }) => { + it(argsType, () => { + if (shouldThrow) { + // throws and the matcher does not match + expect(() => { + expect(callback).toThrowInConnectedCallback(...args); + }).toThrow(/Expected connectedCallback/); + } else { + // does not throw, so we ignore the matcher + expect(callback).not.toThrowInConnectedCallback(...args); + } + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/src/modules/smoke/matchers/matchers.js b/test/src/modules/smoke/matchers/matchers.js new file mode 100644 index 00000000..13b34ffd --- /dev/null +++ b/test/src/modules/smoke/matchers/matchers.js @@ -0,0 +1,12 @@ +import { LightningElement, api } from 'lwc'; + +export default class Matchers extends LightningElement { + @api + throwInConnectedCallback; + + connectedCallback() { + if (this.throwInConnectedCallback) { + throw new ReferenceError('This is an error thrown from connectedCallback'); + } + } +}