diff --git a/SUMMARY.md b/SUMMARY.md index 5458ac3d9..e0c1ff0a2 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -65,6 +65,7 @@ * [setState(nextState[, callback])](/docs/api/ShallowWrapper/setState.md) * [shallow([options])](/docs/api/ShallowWrapper/shallow.md) * [simulate(event[, data])](/docs/api/ShallowWrapper/simulate.md) + * [simulateError(error)](/docs/api/ShallowWrapper/simulateError.md) * [slice([begin[, end]])](/docs/api/ShallowWrapper/slice.md) * [some(selector)](/docs/api/ShallowWrapper/some.md) * [someWhere(predicate)](/docs/api/ShallowWrapper/someWhere.md) @@ -121,6 +122,7 @@ * [setProps(nextProps[, callback])](/docs/api/ReactWrapper/setProps.md) * [setState(nextState[, callback])](/docs/api/ReactWrapper/setState.md) * [simulate(event[, data])](/docs/api/ReactWrapper/simulate.md) + * [simulateError(error)](/docs/api/ReactWrapper/simulateError.md) * [slice([begin[, end]])](/docs/api/ReactWrapper/slice.md) * [some(selector)](/docs/api/ReactWrapper/some.md) * [someWhere(predicate)](/docs/api/ReactWrapper/someWhere.md) diff --git a/docs/api/ReactWrapper/simulateError.md b/docs/api/ReactWrapper/simulateError.md new file mode 100644 index 000000000..c34b8c2df --- /dev/null +++ b/docs/api/ReactWrapper/simulateError.md @@ -0,0 +1,65 @@ +# `.simulateError(error) => Self` + +Simulate a component throwing an error as part of its rendering lifecycle. + +This is particularly useful in combination with React 16 error boundaries (ie, the `componentDidCatch` lifecycle method). + + +#### Arguments + +1. `error` (`Any`): The error to throw. + + + +#### Returns + +`ReactWrapper`: Returns itself. + + + +#### Example + +```jsx +function Something() { + // this is just a placeholder + return null; +} + +class ErrorBoundary extends React.Component { + componentDidCatch(error, info) { + const { spy } = this.props; + spy(error, info); + } + + render() { + const { children } = this.props; + return ( + + {children} + + ); + } +} +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + spy: PropTypes.func.isRequired, +}; + +const spy = sinon.spy(); +const wrapper = mount(); +const error = new Error('hi!'); +wrapper.find(Something).simulateError(error); + +expect(spy).to.have.property('callCount', 1); +expect(spy.args).to.deep.equal([ + error, + { + componentStack: ` + in Something (created by ErrorBoundary) + in ErrorBoundary (created by WrapperComponent) + in WrapperComponent`, + }, +]); +``` + + diff --git a/docs/api/ShallowWrapper/simulateError.md b/docs/api/ShallowWrapper/simulateError.md new file mode 100644 index 000000000..cdc0331d8 --- /dev/null +++ b/docs/api/ShallowWrapper/simulateError.md @@ -0,0 +1,65 @@ +# `.simulateError(error) => Self` + +Simulate a component throwing an error as part of its rendering lifecycle. + +This is particularly useful in combination with React 16 error boundaries (ie, the `componentDidCatch` lifecycle method). + + +#### Arguments + +1. `error` (`Any`): The error to throw. + + + +#### Returns + +`ShallowWrapper`: Returns itself. + + + +#### Example + +```jsx +function Something() { + // this is just a placeholder + return null; +} + +class ErrorBoundary extends React.Component { + componentDidCatch(error, info) { + const { spy } = this.props; + spy(error, info); + } + + render() { + const { children } = this.props; + return ( + + {children} + + ); + } +} +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + spy: PropTypes.func.isRequired, +}; + +const spy = sinon.spy(); +const wrapper = shallow(); +const error = new Error('hi!'); +wrapper.find(Something).simulateError(error); + +expect(spy).to.have.property('callCount', 1); +expect(spy.args).to.deep.equal([ + error, + { + componentStack: ` + in Something (created by ErrorBoundary) + in ErrorBoundary (created by WrapperComponent) + in WrapperComponent`, + }, +]); +``` + + diff --git a/packages/enzyme-adapter-react-16.1/src/ReactSixteenOneAdapter.js b/packages/enzyme-adapter-react-16.1/src/ReactSixteenOneAdapter.js index 4da8391e7..23bdcfef9 100644 --- a/packages/enzyme-adapter-react-16.1/src/ReactSixteenOneAdapter.js +++ b/packages/enzyme-adapter-react-16.1/src/ReactSixteenOneAdapter.js @@ -27,6 +27,7 @@ import { createMountWrapper, propsWithKeysAndRef, ensureKeyOrUndefined, + simulateError, } from 'enzyme-adapter-utils'; import { findCurrentFiberUsingSlowPath } from 'react-reconciler/reflection'; @@ -262,6 +263,19 @@ class ReactSixteenOneAdapter extends EnzymeAdapter { getNode() { return instance ? toTree(instance._reactInternalFiber).rendered : null; }, + simulateError(nodeHierarchy, rootNode, error) { + const { instance: catchingInstance } = nodeHierarchy + .find(x => x.instance && x.instance.componentDidCatch) || {}; + + simulateError( + error, + catchingInstance, + rootNode, + nodeHierarchy, + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, mock) { const mappedEvent = mapNativeEventNames(event, eventOptions); const eventFn = TestUtils.Simulate[mappedEvent]; @@ -279,6 +293,7 @@ class ReactSixteenOneAdapter extends EnzymeAdapter { } createShallowRenderer(/* options */) { + const adapter = this; const renderer = new ShallowRenderer(); let isDOM = false; let cachedNode = null; @@ -327,6 +342,16 @@ class ReactSixteenOneAdapter extends EnzymeAdapter { : elementToTree(output), }; }, + simulateError(nodeHierarchy, rootNode, error) { + simulateError( + error, + renderer._instance, + cachedNode, + nodeHierarchy.concat(cachedNode), + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, ...args) { const handler = node.props[propFromEvent(event, eventOptions)]; if (handler) { diff --git a/packages/enzyme-adapter-react-16.2/src/ReactSixteenTwoAdapter.js b/packages/enzyme-adapter-react-16.2/src/ReactSixteenTwoAdapter.js index 15845bddb..595bff921 100644 --- a/packages/enzyme-adapter-react-16.2/src/ReactSixteenTwoAdapter.js +++ b/packages/enzyme-adapter-react-16.2/src/ReactSixteenTwoAdapter.js @@ -28,6 +28,7 @@ import { createMountWrapper, propsWithKeysAndRef, ensureKeyOrUndefined, + simulateError, } from 'enzyme-adapter-utils'; import { findCurrentFiberUsingSlowPath } from 'react-reconciler/reflection'; @@ -264,6 +265,19 @@ class ReactSixteenTwoAdapter extends EnzymeAdapter { getNode() { return instance ? toTree(instance._reactInternalFiber).rendered : null; }, + simulateError(nodeHierarchy, rootNode, error) { + const { instance: catchingInstance } = nodeHierarchy + .find(x => x.instance && x.instance.componentDidCatch) || {}; + + simulateError( + error, + catchingInstance, + rootNode, + nodeHierarchy, + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, mock) { const mappedEvent = mapNativeEventNames(event, eventOptions); const eventFn = TestUtils.Simulate[mappedEvent]; @@ -281,6 +295,7 @@ class ReactSixteenTwoAdapter extends EnzymeAdapter { } createShallowRenderer(/* options */) { + const adapter = this; const renderer = new ShallowRenderer(); let isDOM = false; let cachedNode = null; @@ -329,6 +344,16 @@ class ReactSixteenTwoAdapter extends EnzymeAdapter { : elementToTree(output), }; }, + simulateError(nodeHierarchy, rootNode, error) { + simulateError( + error, + renderer._instance, + cachedNode, + nodeHierarchy.concat(cachedNode), + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, ...args) { const handler = node.props[propFromEvent(event, eventOptions)]; if (handler) { diff --git a/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js b/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js index ed8e46ab5..23c6ebb1a 100644 --- a/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js +++ b/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js @@ -34,6 +34,7 @@ import { createMountWrapper, propsWithKeysAndRef, ensureKeyOrUndefined, + simulateError, } from 'enzyme-adapter-utils'; import { findCurrentFiberUsingSlowPath } from 'react-reconciler/reflection'; @@ -283,6 +284,19 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter { getNode() { return instance ? toTree(instance._reactInternalFiber).rendered : null; }, + simulateError(nodeHierarchy, rootNode, error) { + const { instance: catchingInstance } = nodeHierarchy + .find(x => x.instance && x.instance.componentDidCatch) || {}; + + simulateError( + error, + catchingInstance, + rootNode, + nodeHierarchy, + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, mock) { const mappedEvent = mapNativeEventNames(event, eventOptions); const eventFn = TestUtils.Simulate[mappedEvent]; @@ -300,6 +314,7 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter { } createShallowRenderer(/* options */) { + const adapter = this; const renderer = new ShallowRenderer(); let isDOM = false; let cachedNode = null; @@ -348,6 +363,16 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter { : elementToTree(output), }; }, + simulateError(nodeHierarchy, rootNode, error) { + simulateError( + error, + renderer._instance, + cachedNode, + nodeHierarchy.concat(cachedNode), + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, ...args) { const handler = node.props[propFromEvent(event, eventOptions)]; if (handler) { diff --git a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js index 0ac20a998..c5da1951d 100644 --- a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js +++ b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js @@ -35,6 +35,7 @@ import { createMountWrapper, propsWithKeysAndRef, ensureKeyOrUndefined, + simulateError, } from 'enzyme-adapter-utils'; import findCurrentFiberUsingSlowPath from './findCurrentFiberUsingSlowPath'; import detectFiberTags from './detectFiberTags'; @@ -284,6 +285,19 @@ class ReactSixteenAdapter extends EnzymeAdapter { getNode() { return instance ? toTree(instance._reactInternalFiber).rendered : null; }, + simulateError(nodeHierarchy, rootNode, error) { + const { instance: catchingInstance } = nodeHierarchy + .find(x => x.instance && x.instance.componentDidCatch) || {}; + + simulateError( + error, + catchingInstance, + rootNode, + nodeHierarchy, + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, mock) { const mappedEvent = mapNativeEventNames(event, eventOptions); const eventFn = TestUtils.Simulate[mappedEvent]; @@ -300,6 +314,7 @@ class ReactSixteenAdapter extends EnzymeAdapter { } createShallowRenderer(/* options */) { + const adapter = this; const renderer = new ShallowRenderer(); let isDOM = false; let cachedNode = null; @@ -348,6 +363,16 @@ class ReactSixteenAdapter extends EnzymeAdapter { : elementToTree(output), }; }, + simulateError(nodeHierarchy, rootNode, error) { + simulateError( + error, + renderer._instance, + cachedNode, + nodeHierarchy.concat(cachedNode), + nodeTypeFromType, + adapter.displayNameOfNode, + ); + }, simulateEvent(node, event, ...args) { const handler = node.props[propFromEvent(event, eventOptions)]; if (handler) { diff --git a/packages/enzyme-adapter-utils/src/Utils.js b/packages/enzyme-adapter-utils/src/Utils.js index ad8c381c7..a4b1407c1 100644 --- a/packages/enzyme-adapter-utils/src/Utils.js +++ b/packages/enzyme-adapter-utils/src/Utils.js @@ -233,3 +233,49 @@ export function propsWithKeysAndRef(node) { } return node.props; } + +function getComponentStack( + hierarchy, + getNodeType = nodeTypeFromType, + getDisplayName = displayNameOfNode, +) { + const tuples = hierarchy.map(x => [ + getNodeType(x.type), + getDisplayName(x), + ]).concat([[ + 'class', + 'WrapperComponent', + ]]); + + return tuples.map(([, name], i, arr) => { + const [, closestComponent] = arr.slice(i + 1).find(([nodeType]) => nodeType !== 'host') || []; + return `\n in ${name}${closestComponent ? ` (created by ${closestComponent})` : ''}`; + }).join(''); +} + +export function simulateError( + error, + catchingInstance, + rootNode, + hierarchy, + getNodeType = nodeTypeFromType, + getDisplayName = displayNameOfNode, +) { + const nodeType = getNodeType(rootNode.type); + if (nodeType !== 'class') { + throw new TypeError('simulateError() can only be called on class components with an instance'); + } + + const { componentDidCatch } = catchingInstance || {}; + if (!componentDidCatch) { + throw error; + } + + const componentStack = getComponentStack( + hierarchy, + getNodeType, + getDisplayName, + ); + + componentDidCatch.call(catchingInstance, error, { componentStack }); +} diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index 55568db3d..fd4f8c5e9 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -2641,6 +2641,86 @@ describeWithDOM('mount', () => { }); }); + describe('.simulateError(error)', () => { + class Div extends React.Component { + render() { + return
{this.props.children}
; + } + } + + class Spans extends React.Component { + render() { + return
; + } + } + + class Nested extends React.Component { + render() { + return
; + } + } + + it('throws on host elements', () => { + const wrapper = mount(
).find('div'); + expect(wrapper.is('div')).to.equal(true); + expect(() => wrapper.simulateError()).to.throw(); + }); + + it('throws on "not one" node', () => { + const wrapper = mount(); + + const spans = wrapper.find('span'); + expect(spans).to.have.lengthOf(2); + expect(() => spans.simulateError()).to.throw(); + + const navs = wrapper.find('nav'); + expect(navs).to.have.lengthOf(0); + expect(() => navs.simulateError()).to.throw(); + }); + + it('throws when the renderer lacks `simulateError`', () => { + const wrapper = mount(); + delete wrapper[sym('__renderer__')].simulateError; + expect(() => wrapper.simulateError()).to.throw(); + try { + wrapper.simulateError(); + } catch (e) { + expect(e).not.to.equal(undefined); + } + }); + + it('calls through to renderer’s `simulateError`', () => { + const wrapper = mount().find(Div); + const stub = sinon.stub().callsFake((_, __, e) => { throw e; }); + wrapper[sym('__renderer__')].simulateError = stub; + const error = new Error('hi'); + expect(() => wrapper.simulateError(error)).to.throw(error); + expect(stub).to.have.property('callCount', 1); + + const [args] = stub.args; + expect(args).to.have.lengthOf(3); + const [hierarchy, rootNode, actualError] = args; + expect(actualError).to.equal(error); + expect(rootNode).to.eql(wrapper[sym('__root__')].getNodeInternal()); + expect(hierarchy).to.have.lengthOf(2); + const [divNode, spanNode] = hierarchy; + expect(divNode).to.contain.keys({ + type: Div, + nodeType: 'class', + rendered: { + type: Spans, + nodeType: 'class', + rendered: null, + }, + }); + expect(spanNode).to.contain.keys({ + type: Spans, + nodeType: 'class', + rendered: null, + }); + }); + }); + describe('.setState(newState[, callback])', () => { it('throws on a non-function callback', () => { class Foo extends React.Component { @@ -4930,6 +5010,118 @@ describeWithDOM('mount', () => { }); }); + describeIf(is('>= 16'), 'componentDidCatch', () => { + describe('errors inside an error boundary', () => { + const errorToThrow = new EvalError('threw an error!'); + // in React 16.0 - 16.2, and some older nodes, the actual error thrown isn't reported. + const reactError = new Error('An error was thrown inside one of your components, but React doesn\'t know what it was. This is likely due to browser flakiness. React does its best to preserve the "Pause on exceptions" behavior of the DevTools, which requires some DEV-mode only tricks. It\'s possible that these don\'t work in your browser. Try triggering the error in production mode, or switching to a modern browser. If you suspect that this is actually an issue with React, please file an issue.'); + const properErrorMessage = error => error instanceof Error && ( + error.message === errorToThrow.message + || error.message === reactError.message + ); + + const hasFragments = is('>= 16.2'); + const MaybeFragment = hasFragments ? Fragment : 'main'; + + function Thrower({ throws }) { + if (throws) { + throw errorToThrow; + } + return null; + } + + class ErrorBoundary extends React.Component { + constructor(...args) { + super(...args); + this.state = { throws: false }; + } + + componentDidCatch(error, info) { + const { spy } = this.props; + spy(error, info); + this.setState({ throws: false }); + } + + render() { + const { throws } = this.state; + return ( +
+ + + + + +
+ ); + } + } + + describe('Thrower', () => { + it('does not throw when `throws` is `false`', () => { + expect(() => mount()).not.to.throw(); + }); + + it('throws when `throws` is `true`', () => { + expect(() => mount()).to.throw(); + try { + mount(); + expect(true).to.equal(false, 'this line should not be reached'); + } catch (e) { + expect(e).to.satisfy(properErrorMessage); + } + }); + }); + + it('catches a simulated error', () => { + const spy = sinon.spy(); + const wrapper = mount(); + + expect(spy).to.have.property('callCount', 0); + + expect(() => wrapper.find(Thrower).simulateError(errorToThrow)).not.to.throw(); + + expect(spy).to.have.property('callCount', 1); + + expect(spy.args).to.be.an('array').and.have.lengthOf(1); + const [[actualError, info]] = spy.args; + expect(actualError).to.equal(errorToThrow); + expect(info).to.deep.equal({ + componentStack: ` + in Thrower (created by ErrorBoundary) + in span (created by ErrorBoundary)${hasFragments ? '' : ` + in main (created by ErrorBoundary)`} + in div (created by ErrorBoundary) + in ErrorBoundary (created by WrapperComponent) + in WrapperComponent`, + }); + }); + + it('catches errors during render', () => { + const spy = sinon.spy(); + const wrapper = mount(); + + expect(spy).to.have.property('callCount', 0); + + wrapper.setState({ throws: true }); + + expect(spy).to.have.property('callCount', 1); + + expect(spy.args).to.be.an('array').and.have.lengthOf(1); + const [[actualError, info]] = spy.args; + expect(actualError).to.satisfy(properErrorMessage); + expect(info).to.deep.equal({ + componentStack: ` + in Thrower (created by ErrorBoundary) + in span (created by ErrorBoundary)${hasFragments ? '' : ` + in main (created by ErrorBoundary)`} + in div (created by ErrorBoundary) + in ErrorBoundary (created by WrapperComponent) + in WrapperComponent`, + }); + }); + }); + }); + context('mounting phase', () => { it('calls componentWillMount and componentDidMount', () => { const spy = sinon.spy(); diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index ae4fc49c6..13ce6c5b1 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -2336,6 +2336,87 @@ describe('shallow', () => { }); }); + describe('.simulateError(error)', () => { + class Div extends React.Component { + render() { + return
{this.props.children}
; + } + } + + class Spans extends React.Component { + render() { + return
; + } + } + + class Nested extends React.Component { + render() { + return
; + } + } + + it('throws on host elements', () => { + const wrapper = shallow(
); + expect(wrapper.is('div')).to.equal(true); + expect(() => wrapper.simulateError()).to.throw(); + }); + + it('throws on "not one" node', () => { + const wrapper = shallow(); + + const spans = wrapper.find('span'); + expect(spans).to.have.lengthOf(2); + expect(() => spans.simulateError()).to.throw(); + + const navs = wrapper.find('nav'); + expect(navs).to.have.lengthOf(0); + expect(() => navs.simulateError()).to.throw(); + }); + + it('throws when the renderer lacks `simulateError`', () => { + const wrapper = shallow(); + delete wrapper[sym('__renderer__')].simulateError; + expect(() => wrapper.simulateError()).to.throw(); + try { + wrapper.simulateError(); + } catch (e) { + expect(e).not.to.equal(undefined); + } + }); + + it('calls through to renderer’s `simulateError`', () => { + const wrapper = shallow(); + const stub = sinon.stub().callsFake((_, __, e) => { throw e; }); + wrapper[sym('__renderer__')].simulateError = stub; + const error = new Error('hi'); + expect(() => wrapper.simulateError(error)).to.throw(error); + expect(stub).to.have.property('callCount', 1); + + const [args] = stub.args; + expect(args).to.have.lengthOf(3); + const [hierarchy, rootNode, actualError] = args; + expect(actualError).to.equal(error); + expect(rootNode).to.eql(wrapper[sym('__root__')].getNodeInternal()); + expect(hierarchy).to.have.lengthOf(1); + const [node] = hierarchy; + expect(node).to.contain.keys({ + type: Div, + nodeType: 'class', + rendered: { + type: Spans, + nodeType: 'class', + rendered: null, + }, + }); + }); + + it('returns the wrapper', () => { + const wrapper = shallow(); + wrapper[sym('__renderer__')].simulateError = sinon.stub(); + expect(wrapper.simulateError()).to.equal(wrapper); + }); + }); + describe('.setState(newState[, callback])', () => { it('throws on a non-function callback', () => { class Foo extends React.Component { @@ -4969,6 +5050,101 @@ describe('shallow', () => { }); }); + describeIf(is('>= 16'), 'componentDidCatch', () => { + describe('errors inside an error boundary', () => { + const errorToThrow = new EvalError('threw an error!'); + + const hasFragments = is('>= 16.2'); + const MaybeFragment = hasFragments ? Fragment : 'main'; + + function Thrower({ throws }) { + if (throws) { + throw errorToThrow; + } + return null; + } + + class ErrorBoundary extends React.Component { + constructor(...args) { + super(...args); + this.state = { throws: false }; + } + + componentDidCatch(error, info) { + const { spy } = this.props; + spy(error, info); + this.setState({ throws: false }); + } + + render() { + const { throws } = this.state; + return ( +
+ + + + + +
+ ); + } + } + + describe('Thrower', () => { + it('does not throw when `throws` is `false`', () => { + expect(() => shallow()).not.to.throw(); + }); + + it('throws when `throws` is `true`', () => { + expect(() => shallow()).to.throw(errorToThrow); + }); + }); + + it('catches a simulated error', () => { + const spy = sinon.spy(); + const wrapper = shallow(); + + expect(spy).to.have.property('callCount', 0); + + expect(() => wrapper.find(Thrower).simulateError(errorToThrow)).not.to.throw(); + + expect(spy).to.have.property('callCount', 1); + + expect(spy.args).to.be.an('array').and.have.lengthOf(1); + const [[actualError, info]] = spy.args; + expect(() => { throw actualError; }).to.throw(errorToThrow); + expect(info).to.deep.equal({ + componentStack: ` + in Thrower (created by ErrorBoundary) + in span (created by ErrorBoundary)${hasFragments ? '' : ` + in main (created by ErrorBoundary)`} + in div (created by ErrorBoundary) + in ErrorBoundary (created by WrapperComponent) + in WrapperComponent`, + }); + }); + + it('does not catch errors during shallow render', () => { + const spy = sinon.spy(); + const wrapper = shallow(); + + expect(spy).to.have.property('callCount', 0); + + wrapper.setState({ throws: true }); + + expect(spy).to.have.property('callCount', 0); + + const thrower = wrapper.find(Thrower); + expect(thrower).to.have.lengthOf(1); + expect(thrower.props()).to.have.property('throws', true); + + expect(() => thrower.dive()).to.throw(errorToThrow); + + expect(spy).to.have.property('callCount', 0); + }); + }); + }); + context('mounting phase', () => { it('calls componentWillMount and componentDidMount', () => { const spy = sinon.spy(); diff --git a/packages/enzyme/src/ReactWrapper.js b/packages/enzyme/src/ReactWrapper.js index 73a799e05..e3d577334 100644 --- a/packages/enzyme/src/ReactWrapper.js +++ b/packages/enzyme/src/ReactWrapper.js @@ -57,6 +57,10 @@ function filterWhereUnwrapped(wrapper, predicate) { return wrapper.wrap(wrapper.getNodesInternal().filter(predicate).filter(Boolean)); } +function nodeParents(wrapper, node) { + return parentsOfNode(node, wrapper[ROOT].getNodeInternal()); +} + function privateSetNodes(wrapper, nodes) { if (!nodes) { privateSet(wrapper, NODE, null); @@ -614,6 +618,35 @@ class ReactWrapper { }); } + /** + * Used to simulate throwing a rendering error. Pass an error to throw. + * + * @param {String} error + * @returns {ReactWrapper} + */ + simulateError(error) { + if (this[ROOT] === this) { + throw new Error('ReactWrapper::simulateError() may not be called on the root'); + } + + return this.single('simulateError', (thisNode) => { + if (thisNode.nodeType === 'host') { + throw new Error('ReactWrapper::simulateError() can only be called on custom components'); + } + + const renderer = this[RENDERER]; + if (typeof renderer.simulateError !== 'function') { + throw new TypeError('your adapter does not support `simulateError`. Try upgrading it!'); + } + + const rootNode = this[ROOT].getNodeInternal(); + const nodeHierarchy = [thisNode].concat(nodeParents(this, thisNode)); + renderer.simulateError(nodeHierarchy, rootNode, error); + + return this; + }); + } + /** * Returns the props hash for the root node of the wrapper. * @@ -703,7 +736,7 @@ class ReactWrapper { * @returns {ReactWrapper} */ parents(selector) { - const allParents = this.wrap(this.single('parents', n => parentsOfNode(n, this[ROOT].getNodeInternal()))); + const allParents = this.wrap(this.single('parents', n => nodeParents(this, n))); return selector ? allParents.filter(selector) : allParents; } diff --git a/packages/enzyme/src/ShallowWrapper.js b/packages/enzyme/src/ShallowWrapper.js index 469de0b64..18a946cdf 100644 --- a/packages/enzyme/src/ShallowWrapper.js +++ b/packages/enzyme/src/ShallowWrapper.js @@ -147,6 +147,10 @@ function getRootNodeInternal(wrapper) { return wrapper[ROOT][NODE]; } +function nodeParents(wrapper, node) { + return parentsOfNode(node, getRootNodeInternal(wrapper)); +} + function privateSetNodes(wrapper, nodes) { if (!Array.isArray(nodes)) { privateSet(wrapper, NODE, nodes); @@ -850,6 +854,33 @@ class ShallowWrapper { }); } + /** + * Used to simulate throwing a rendering error. Pass an error to throw. + * + * @param {String} error + * @returns {ShallowWrapper} + */ + simulateError(error) { + // in shallow, the "root" is the "rendered" thing. + + return this.single('simulateError', (thisNode) => { + if (thisNode.nodeType === 'host') { + throw new TypeError('ShallowWrapper::simulateError() can only be called on custom components'); + } + + const renderer = this[RENDERER]; + if (typeof renderer.simulateError !== 'function') { + throw new TypeError('your adapter does not support `simulateError`. Try upgrading it!'); + } + + const rootNode = getRootNodeInternal(this); + const nodeHierarchy = [thisNode].concat(nodeParents(this, thisNode)); + renderer.simulateError(nodeHierarchy, rootNode, error); + + return this; + }); + } + /** * Returns the props hash for the current node of the wrapper. * @@ -941,7 +972,7 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ parents(selector) { - const allParents = this.wrap(this.single('parents', n => parentsOfNode(n, getRootNodeInternal(this)))); + const allParents = this.wrap(this.single('parents', n => nodeParents(this, n))); return selector ? allParents.filter(selector) : allParents; }