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 (
+
+ );
+ }
+}
+
+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 (
+
+ );
+ }
+ }
+
+ 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 => (
+
+ );
+
+ 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 (
+
+ );
+ }
+ }
+
+ 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 (
+
+ );
+ }
+ }
+
+ 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 (
+
+ );
+ }
+ }
+
+ 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 (
+
+ );
+ }
+ }
+
+ 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 (
+
+ );
+ }
+ }
+
+ 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 (
+
+ );
+ }
+ }
+
+ 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 }) => (
+
+ );
+
+ 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 }) => (
+
+ );
+
+ 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 }) => (
+
+ );
+
+ 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 = () => (
+
+ );
+
+ 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 = () => (
+
+ );
+
+ 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}
+
);
}
}
+ 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 (
+
+ );
+ }
+ }
+
+ 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',
+ ]);
+ });
+ });
+ });
+});