-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add
expect().toThrowInConnectedCallback
matcher (#210)
Co-authored-by: Ravi Jayaramappa <[email protected]>
- Loading branch information
1 parent
3623be1
commit 5734455
Showing
7 changed files
with
331 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
92 changes: 92 additions & 0 deletions
92
packages/@lwc/jest-preset/src/matchers/expect-throw-in-connected-callback.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'), | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
97
test/src/modules/smoke/matchers/__tests__/matchers.test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} | ||
} |