Skip to content

Commit

Permalink
feat: add expect().toThrowInConnectedCallback matcher (#210)
Browse files Browse the repository at this point in the history
Co-authored-by: Ravi Jayaramappa <[email protected]>
  • Loading branch information
nolanlawson and ravijayaramappa authored Aug 22, 2023
1 parent 3623be1 commit 5734455
Show file tree
Hide file tree
Showing 7 changed files with 331 additions and 1 deletion.
70 changes: 70 additions & 0 deletions packages/@lwc/jest-preset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<js-file-under-test>.test.js` for DOM tests and `<js-file-under-test>.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`.
4 changes: 3 additions & 1 deletion packages/@lwc/jest-preset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
});
2 changes: 2 additions & 0 deletions packages/@lwc/jest-preset/src/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,5 @@ function installRegisterDecoratorsTrap(lwc) {
const lwc = require('@lwc/engine-dom');

installRegisterDecoratorsTrap(lwc);

require('./matchers/expect-throw-in-connected-callback');
55 changes: 55 additions & 0 deletions packages/@lwc/jest-preset/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/* eslint-disable */
// Inspired by:
// - https://unpkg.com/browse/[email protected]/build/index.d.ts
// - https://unpkg.com/browse/@testing-library/[email protected]/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<E, R> {
/**
* @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<R = void, T = {}>
extends LwcJestMatchers<ReturnType<typeof expect.stringContaining>, R> {}
}
}

declare module '@jest/expect' {
export interface Matchers<R extends void | Promise<void>>
extends LwcJestMatchers<ReturnType<typeof expect.stringContaining>, R> {}
}

export {};
97 changes: 97 additions & 0 deletions test/src/modules/smoke/matchers/__tests__/matchers.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
});
});
});
});
});
});
12 changes: 12 additions & 0 deletions test/src/modules/smoke/matchers/matchers.js
Original file line number Diff line number Diff line change
@@ -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');
}
}
}

0 comments on commit 5734455

Please sign in to comment.