diff --git a/docs/README.md b/docs/README.md index 4fe21f837..377344e09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ * [instance()](/docs/api/ShallowWrapper/instance.md) * [is(selector)](/docs/api/ShallowWrapper/is.md) * [isEmpty()](/docs/api/ShallowWrapper/isEmpty.md) + * [invoke(event[, ...args])](/docs/api/ShallowWrapper/invoke.md) * [key()](/docs/api/ShallowWrapper/key.md) * [last()](/docs/api/ShallowWrapper/last.md) * [map(fn)](/docs/api/ShallowWrapper/map.md) @@ -61,7 +62,7 @@ * [setProps(nextProps)](/docs/api/ShallowWrapper/setProps.md) * [setState(nextState[, callback])](/docs/api/ShallowWrapper/setState.md) * [shallow([options])](/docs/api/ShallowWrapper/shallow.md) - * [simulate(event[, data])](/docs/api/ShallowWrapper/simulate.md) + * [simulate(event[, mock])](/docs/api/ShallowWrapper/simulate.md) * [slice([begin[, end]])](/docs/api/ShallowWrapper/slice.md) * [some(selector)](/docs/api/ShallowWrapper/some.md) * [someWhere(predicate)](/docs/api/ShallowWrapper/someWhere.md) diff --git a/docs/api/ShallowWrapper/invoke.md b/docs/api/ShallowWrapper/invoke.md new file mode 100644 index 000000000..cd2d68c24 --- /dev/null +++ b/docs/api/ShallowWrapper/invoke.md @@ -0,0 +1,51 @@ +# `.invoke(event[, ...args]) => Self` + +Invoke event handlers + + +#### Arguments + +1. `event` (`String`): The event name to be invoked +2. `...args` (`Any` [optional]): Arguments that will be passed to the event handler + + + +#### Returns + +`ShallowWrapper`: Returns itself. + + + +#### Example + +```jsx +class Foo extends React.Component { + constructor(props) { + super(props); + this.state = { count: 0 }; + } + render() { + const { count } = this.state; + return ( +
+
+ {count} clicks +
+ this.setState({ count: count + 1 })}> + Increment + +
+ ); + } +} + +const wrapper = shallow(); + +expect(wrapper.find('[data-clicks=0]').length).to.equal(1); +wrapper.find('a').invoke('click'); +expect(wrapper.find('[data-clicks=1]').length).to.equal(1); +``` + +#### Related Methods + +- [`.simulate(event[, mock]) => Self`](simulate.md) diff --git a/docs/api/ShallowWrapper/simulate.md b/docs/api/ShallowWrapper/simulate.md index 512967b49..4add1b2f2 100644 --- a/docs/api/ShallowWrapper/simulate.md +++ b/docs/api/ShallowWrapper/simulate.md @@ -1,13 +1,14 @@ -# `.simulate(event[, ...args]) => Self` +# `.simulate(event[, mock]) => Self` -Simulate events +Simulate events. The propagation behavior of browser events is simulated — however, +this method does not emit an actual event. Event handlers are called with a SyntheticEvent object. #### Arguments 1. `event` (`String`): The event name to be simulated -2. `...args` (`Any` [optional]): A mock event object that will get passed through to the event -handlers. +2. `mock` (`Object` [optional]): A mock event object that will be merged with the event +object passed to the handlers. @@ -50,9 +51,10 @@ expect(wrapper.find('.clicks-1').length).to.equal(1); #### Common Gotchas -- Currently, event simulation for the shallow renderer does not propagate as one would normally -expect in a real environment. As a result, one must call `.simulate()` on the actual node that has -the event handler set. -- Even though the name would imply this simulates an actual event, `.simulate()` will in fact +- Even though the name would imply this simulates an actual event, `.simulate()` will in fact target the component's prop based on the event you give it. For example, `.simulate('click')` will actually get the `onClick` prop and call it. + +#### Related Methods + +- [`.invoke(event[, ...args]) => Self`](invoke.md) diff --git a/docs/api/shallow.md b/docs/api/shallow.md index 820819e38..536912223 100644 --- a/docs/api/shallow.md +++ b/docs/api/shallow.md @@ -166,9 +166,12 @@ Returns the named prop of the current node. #### [`.key() => String`](ShallowWrapper/key.md) Returns the key of the current node. -#### [`.simulate(event[, data]) => ShallowWrapper`](ShallowWrapper/simulate.md) +#### [`.simulate(event[, mock]) => Self`](ShallowWrapper/simulate.md) Simulates an event on the current node. +#### [`.invoke(event[, ...args]) => Self`](ShallowWrapper/invoke.md) +Invokes an event handler on the current node. + #### [`.setState(nextState) => ShallowWrapper`](ShallowWrapper/setState.md) Manually sets state of the root component. diff --git a/src/ShallowWrapper.js b/src/ShallowWrapper.js index 1567c689c..fdda1c4c0 100644 --- a/src/ShallowWrapper.js +++ b/src/ShallowWrapper.js @@ -1,4 +1,5 @@ import React from 'react'; +import objectAssign from 'object.assign'; import flatten from 'lodash/flatten'; import unique from 'lodash/uniq'; import compact from 'lodash/compact'; @@ -36,6 +37,7 @@ import { renderToStaticMarkup, batchedUpdates, isDOMComponentElement, + SyntheticEvent, } from './react-compat'; /** @@ -598,26 +600,70 @@ class ShallowWrapper { } /** - * Used to simulate events. Pass an eventname and (optionally) event arguments. This method of - * testing events should be met with some skepticism. + * Used to invoke event handlers. Pass an eventname and (optionally) event + * arguments. This method of testing events should be met with some + * skepticism. * * @param {String} event * @param {Array} args * @returns {ShallowWrapper} */ - simulate(event, ...args) { - const handler = this.prop(propFromEvent(event)); - if (handler) { + invoke(event, ...args) { + return this.single('invoke', () => { + const handler = this.prop(propFromEvent(event)); + if (handler) { + withSetStateAllowed(() => { + batchedUpdates(() => { + handler(...args); + }); + this.root.update(); + }); + } + return this; + }); + } + + /** + * Used to simulate events with propagation. + * Pass an eventname and an object with properties to assign to the event object. + * This method of testing events should be met with some skepticism. + * + * NOTE: can only be called on a wrapper of a single node. + * + * @param {String} event + * @param {Object} mock (optional) + * @returns {ShallowWrapper} + */ + simulate(event, mock) { + return this.single('simulate', () => { + const bubbleProp = propFromEvent(event); + const captureProp = `${bubbleProp}Capture`; + const e = new SyntheticEvent(undefined, undefined, { type: event, target: {} }); + objectAssign(e, mock); withSetStateAllowed(() => { - // TODO(lmr): create/use synthetic events - // TODO(lmr): emulate React's event propagation + const handlers = [ + ...this.parents().map(n => n.prop(captureProp)).reverse(), + this.prop(captureProp), + this.prop(bubbleProp), + ...this.parents().map(n => n.prop(bubbleProp)), + ]; + batchedUpdates(() => { - handler(...args); + handlers.some((handler) => { + if (handler) { + handler(e); + if (e.isPropagationStopped()) { + return true; + } + } + return false; + }); }); + this.root.update(); }); - } - return this; + return this; + }); } /** diff --git a/src/react-compat.js b/src/react-compat.js index 2d108bd30..67c9c2515 100644 --- a/src/react-compat.js +++ b/src/react-compat.js @@ -7,6 +7,7 @@ */ import objectAssign from 'object.assign'; +import SyntheticEvent from 'react/lib/SyntheticEvent'; import { REACT013 } from './version'; let TestUtils; @@ -185,4 +186,5 @@ export { renderWithOptions, unmountComponentAtNode, batchedUpdates, + SyntheticEvent, }; diff --git a/test/ShallowWrapper-spec.jsx b/test/ShallowWrapper-spec.jsx index 234c0e294..615144e04 100644 --- a/test/ShallowWrapper-spec.jsx +++ b/test/ShallowWrapper-spec.jsx @@ -1004,6 +1004,176 @@ describe('shallow', () => { }); }); + describe('.invoke(eventName, data)', () => { + + it('should invoke event handlers without propagation', () => { + + class Foo extends React.Component { + constructor(props) { + super(props); + this.state = { count: 0 }; + this.incrementCount = this.incrementCount.bind(this); + } + + incrementCount() { + this.setState({ count: this.state.count + 1 }); + } + + render() { + return ( +
+ foo +
+ ); + } + } + + const wrapper = shallow(); + + expect(wrapper.find('.clicks-0').length).to.equal(1); + wrapper.find('a').invoke('click'); + expect(wrapper.find('.clicks-1').length).to.equal(1); + + }); + + + it('should pass in arguments', () => { + const spy = sinon.spy(); + class Foo extends React.Component { + render() { + return ( + foo + ); + } + } + + const wrapper = shallow(); + const a = {}; + const b = {}; + + wrapper.invoke('click', a, b); + expect(spy.args[0][0]).to.equal(a); + expect(spy.args[0][1]).to.equal(b); + }); + + describeIf(!REACT013, 'stateless function components', () => { + it('should invoke event handlers', () => { + const spy = sinon.spy(); + const Foo = props => ( +
+ foo +
+ ); + + const wrapper = shallow(); + + expect(spy.calledOnce).to.equal(false); + wrapper.find('a').invoke('click'); + expect(spy.calledOnce).to.equal(true); + }); + + + it('should pass in arguments', () => { + const spy = sinon.spy(); + const Foo = () => ( + foo + ); + + const wrapper = shallow(); + const a = {}; + const b = {}; + + wrapper.invoke('click', a, b); + expect(spy.args[0][0]).to.equal(a); + expect(spy.args[0][1]).to.equal(b); + }); + }); + + describe('Normalizing JS event names', () => { + it('should convert lowercase events to React camelcase', () => { + const spy = sinon.spy(); + const clickSpy = sinon.spy(); + class Foo extends React.Component { + render() { + return ( + foo + ); + } + } + + const wrapper = shallow(); + + wrapper.invoke('dblclick'); + expect(spy.calledOnce).to.equal(true); + + wrapper.invoke('click'); + expect(clickSpy.calledOnce).to.equal(true); + }); + + describeIf(!REACT013, 'normalizing mouseenter', () => { + it('should convert lowercase events to React camelcase', () => { + const spy = sinon.spy(); + class Foo extends React.Component { + render() { + return ( + foo + ); + } + } + + const wrapper = shallow(); + + wrapper.invoke('mouseenter'); + expect(spy.calledOnce).to.equal(true); + }); + + it('should convert lowercase events to React camelcase in stateless components', () => { + const spy = sinon.spy(); + const Foo = () => ( + foo + ); + + const wrapper = shallow(); + + wrapper.invoke('mouseenter'); + expect(spy.calledOnce).to.equal(true); + }); + }); + }); + + it('should be batched updates', () => { + let renderCount = 0; + class Foo extends React.Component { + constructor(props) { + super(props); + this.state = { + count: 0, + }; + this.onClick = this.onClick.bind(this); + } + onClick() { + this.setState({ count: this.state.count + 1 }); + this.setState({ count: this.state.count + 1 }); + } + render() { + renderCount += 1; + return ( + {this.state.count} + ); + } + } + + const wrapper = shallow(); + wrapper.invoke('click'); + expect(wrapper.text()).to.equal('1'); + expect(renderCount).to.equal(2); + }); + + }); + describe('.simulate(eventName, data)', () => { it('should simulate events', () => { class Foo extends React.Component { @@ -1034,8 +1204,237 @@ describe('shallow', () => { expect(wrapper.find('.clicks-1').length).to.equal(1); }); + it('should call handler with SyntheticEvent object', () => { + const spy = sinon.spy(); + class Bar extends React.Component { + render() { + return
bar
; + } + } + + const wrapper = shallow(); + + wrapper.simulate('click'); + + const event = spy.lastCall.args[0]; + expect(event.stopPropagation).to.be.a('function'); + expect(event.preventDefault).to.be.a('function'); + }); + + it('should propagate events triggered on native elements', () => { + class Foo extends React.Component { + render() { + return ( +
this.props.calls.push('div bubble')} + onClickCapture={() => this.props.calls.push('div capture')} + > + this.props.calls.push('span bubble')} + onClickCapture={() => this.props.calls.push('span capture')} + > + this.props.calls.push('a bubble')} + onClickCapture={() => this.props.calls.push('a capture')} + > + foo + + +
+ ); + } + } + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(actualCalls.length).to.equal(6); + expect(actualCalls).to.eql([ + 'div capture', + 'span capture', + 'a capture', + 'a bubble', + 'span bubble', + 'div bubble', + ]); + }); + + it('should propagate events triggered on composite elements', () => { + class Bar extends React.Component { + render() { + return
bar
; + } + } + + class Foo extends React.Component { + render() { + return ( +
this.props.calls.push('div bubble')} + onClickCapture={() => this.props.calls.push('div capture')} + > + this.props.calls.push('span bubble')} + onClickCapture={() => this.props.calls.push('span capture')} + > + this.props.calls.push('a bubble')} + onClickCapture={() => this.props.calls.push('a capture')} + > + foo + + +
+ ); + } + } + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find(Bar).simulate('click'); + expect(actualCalls.length).to.equal(6); + expect(actualCalls).to.eql([ + 'div capture', + 'span capture', + 'a capture', + 'a bubble', + 'span bubble', + 'div bubble', + ]); + }); + + it('should skip over parent nodes without handlers', () => { + class Foo extends React.Component { + render() { + return ( +
this.props.calls.push('div bubble')} + > + this.props.calls.push('span capture')} + > + this.props.calls.push('a bubble')} + onClickCapture={() => this.props.calls.push('a capture')} + > + foo + + +
+ ); + } + } + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(actualCalls.length).to.equal(4); + expect(actualCalls).to.eql([ + 'span capture', + 'a capture', + 'a bubble', + 'div bubble', + ]); + }); + + it('should not call handlers for other events', () => { + class Foo extends React.Component { + render() { + return ( +
this.props.calls.push('onChange')} + onClick={() => this.props.calls.push('div bubble')} + onClickCapture={() => this.props.calls.push('div capture')} + > + this.props.calls.push('span bubble')} + onClickCapture={() => this.props.calls.push('span capture')} + > + this.props.calls.push('a bubble')} + onClickCapture={() => this.props.calls.push('a capture')} + > + foo + + +
+ ); + } + } + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(actualCalls.length).to.equal(6); + expect(actualCalls).to.eql([ + 'div capture', + 'span capture', + 'a capture', + 'a bubble', + 'span bubble', + 'div bubble', + ]); + }); + + it('should respect stopPropagation called in the bubbling phase', () => { + const innerOnClick = sinon.spy((e) => { + e.stopPropagation(); + }); + const outerOnClick = sinon.spy(); + const outerOnClickCapture = sinon.spy(); + class Foo extends React.Component { + render() { + return ( +
+ foo +
+ ); + } + } + + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(outerOnClickCapture.calledOnce).to.equal(true); + expect(innerOnClick.calledOnce).to.equal(true); + expect(outerOnClick.calledOnce).to.equal(false); + }); + + it('should respect stopPropagation called in the capture phase', () => { + const innerOnClick = sinon.spy((e) => { + e.stopPropagation(); + }); + const outerOnClick = sinon.spy(); + const outerOnClickCapture = sinon.spy(); + class Foo extends React.Component { + render() { + return ( +
+ foo +
+ ); + } + } + + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(outerOnClickCapture.calledOnce).to.equal(true); + expect(innerOnClick.calledOnce).to.equal(true); + expect(outerOnClick.calledOnce).to.equal(false); + }); - it('should pass in event data', () => { + it('should assign mock data to SyntheticEvent', () => { const spy = sinon.spy(); class Foo extends React.Component { render() { @@ -1046,12 +1445,30 @@ describe('shallow', () => { } const wrapper = shallow(); - const a = {}; - const b = {}; + const a = { abc: {} }; - wrapper.simulate('click', a, b); - expect(spy.args[0][0]).to.equal(a); - expect(spy.args[0][1]).to.equal(b); + wrapper.simulate('click', a); + expect(spy.args[0][0].abc).to.equal(a.abc); + }); + + it('should throw if called on mulitple nodes', () => { + const spy = sinon.spy(); + class Foo extends React.Component { + render() { + return ( +
+ foo + bar +
+ ); + } + } + + const wrapper = shallow(); + + const anchors = wrapper.find('a'); + + expect(() => anchors.simulate('click')).to.throw(); }); describeIf(!REACT013, 'stateless function components', () => { @@ -1075,12 +1492,197 @@ describe('shallow', () => { ); const wrapper = shallow(); - const a = {}; - const b = {}; + const a = { abc: {} }; - wrapper.simulate('click', a, b); - expect(spy.args[0][0]).to.equal(a); - expect(spy.args[0][1]).to.equal(b); + wrapper.simulate('click', a); + expect(spy.args[0][0].abc).to.equal(a.abc); + }); + + it('should propagate events triggered on native elements', () => { + const Foo = ({ calls }) => ( +
calls.push('div bubble')} + onClickCapture={() => calls.push('div capture')} + > + calls.push('span bubble')} + onClickCapture={() => calls.push('span capture')} + > + calls.push('a bubble')} + onClickCapture={() => calls.push('a capture')} + > + foo + + +
+ ); + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(actualCalls.length).to.equal(6); + expect(actualCalls).to.eql([ + 'div capture', + 'span capture', + 'a capture', + 'a bubble', + 'span bubble', + 'div bubble', + ]); + }); + + it('should propagate events triggered on composite elements', () => { + const Bar = () => ( +
bar
+ ); + + const Foo = ({ calls }) => ( +
calls.push('div bubble')} + onClickCapture={() => calls.push('div capture')} + > + calls.push('span bubble')} + onClickCapture={() => calls.push('span capture')} + > + calls.push('a bubble')} + onClickCapture={() => calls.push('a capture')} + > + foo + + +
+ ); + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find(Bar).simulate('click'); + expect(actualCalls.length).to.equal(6); + expect(actualCalls).to.eql([ + 'div capture', + 'span capture', + 'a capture', + 'a bubble', + 'span bubble', + 'div bubble', + ]); + }); + + it('should skip over parent nodes without handlers', () => { + const Foo = ({ calls }) => ( +
calls.push('div bubble')} + > + calls.push('span capture')} + > + calls.push('a bubble')} + onClickCapture={() => calls.push('a capture')} + > + foo + + +
+ ); + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(actualCalls.length).to.equal(4); + expect(actualCalls).to.eql([ + 'span capture', + 'a capture', + 'a bubble', + 'div bubble', + ]); + }); + + it('should not call handlers for other events', () => { + const Foo = ({ calls }) => ( +
calls.push('onChange')} + onClick={() => calls.push('div bubble')} + onClickCapture={() => calls.push('div capture')} + > + calls.push('span bubble')} + onClickCapture={() => calls.push('span capture')} + > + calls.push('a bubble')} + onClickCapture={() => calls.push('a capture')} + > + foo + + +
+ ); + + const actualCalls = []; + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(actualCalls.length).to.equal(6); + expect(actualCalls).to.eql([ + 'div capture', + 'span capture', + 'a capture', + 'a bubble', + 'span bubble', + 'div bubble', + ]); + }); + + it('should respect stopPropagation called in the bubbling phase', () => { + const innerOnClick = sinon.spy((e) => { + e.stopPropagation(); + }); + const outerOnClick = sinon.spy(); + const outerOnClickCapture = sinon.spy(); + const Foo = () => ( +
+ foo +
+ ); + + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(outerOnClickCapture.calledOnce).to.equal(true); + expect(innerOnClick.calledOnce).to.equal(true); + expect(outerOnClick.calledOnce).to.equal(false); + }); + + it('should respect stopPropagation called in the capture phase', () => { + const innerOnClick = sinon.spy((e) => { + e.stopPropagation(); + }); + const outerOnClick = sinon.spy(); + const outerOnClickCapture = sinon.spy(); + const Foo = () => ( +
+ foo +
+ ); + + const wrapper = shallow(); + + wrapper.find('a').simulate('click'); + expect(outerOnClickCapture.calledOnce).to.equal(true); + expect(innerOnClick.calledOnce).to.equal(true); + expect(outerOnClick.calledOnce).to.equal(false); }); }); @@ -1144,22 +1746,32 @@ describe('shallow', () => { this.state = { count: 0, }; - this.onClick = this.onClick.bind(this); } onClick() { this.setState({ count: this.state.count + 1 }); this.setState({ count: this.state.count + 1 }); } + outerOnClick() { + this.setState({ count: this.state.count + 1 }); + this.setState({ count: this.state.count + 1 }); + } render() { renderCount += 1; return ( - {this.state.count} +
this.outerOnClick()}> + this.onClick()}>{this.state.count} +
); } } + sinon.spy(Foo.prototype, 'outerOnClick'); + sinon.spy(Foo.prototype, 'onClick'); + const wrapper = shallow(); - wrapper.simulate('click'); + wrapper.find('a').simulate('click'); + expect(Foo.prototype.onClick.calledOnce).to.equal(true); + expect(Foo.prototype.outerOnClick.calledOnce).to.equal(true); expect(wrapper.text()).to.equal('1'); expect(renderCount).to.equal(2); }); diff --git a/test/parity-spec.jsx b/test/parity-spec.jsx new file mode 100644 index 000000000..8758ffc38 --- /dev/null +++ b/test/parity-spec.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describeWithDOM } from './_helpers'; +import { mount, shallow } from '../src/'; + +const renderers = [shallow, mount]; + +describeWithDOM('shallow-mount parity', () => { + describe('simulate(event, mock)', () => { + it('should propagate events', () => { + class Foo extends React.Component { + render() { + return ( +
this.props.calls.push('onChange')} + onClick={() => this.props.calls.push('div bubble')} + onClickCapture={() => this.props.calls.push('div capture')} + > + this.props.calls.push('span bubble')} + onClickCapture={() => this.props.calls.push('span capture')} + > + this.props.calls.push('a bubble')} + onClickCapture={() => this.props.calls.push('a capture')} + > + foo + + +
+ ); + } + } + + renderers.forEach((renderer) => { + const actualCalls = []; + const wrapper = renderer(); + + wrapper.find('a').simulate('click'); + expect(actualCalls.length).to.equal(6); + expect(actualCalls).to.eql([ + 'div capture', + 'span capture', + 'a capture', + 'a bubble', + 'span bubble', + 'div bubble', + ]); + }); + }); + }); +});