What?
Synthetic events are, by definition, events that do not belong natively to the browser. Instead, a Synthetic Event is a wrapper that some JavaScript frameworks use around a browser’s event specification. In modern web development, Synthetic Events are probably most well known for their use in React.
Why?
console.log
ing the event
emitted from a native click
event looks very different than an onClick
event in React, especially the prototype
of the two events. The native event returns an EventPrototype
object while the synthetic event returns a generic Object
prototype. A jquery
event is also called with a generic Object
, albeit with different attributes than the synthetic event.
click {
target: button#button,
buttons: 0,
clientX: 42,
clientY: 22,
layerX: 42,
layerY: 22
}
Object {
_reactName: "onClick",
_targetInst: null,
type: "click",
nativeEvent: click,
target: button,
currentTarget: button,
eventPhase: 3,
bubbles: true,
cancelable: true,
timeStamp: 33225,
...
}
Object {
originalEvent: click,
type: "click",
isDefaultPrevented: ha(),
timeStamp: 73322,
jQuery222049259296493923765: true,
toElement: undefined,
screenY: 298,
screenX: 125,
pageY: 98,
pageX: 60,
...
}
The new-ish HTML5 input types
, especially ones that offer custom UI such as type="range"
and type="color"
, emit even more complicated events. These components are internally running a form of JavaScript, but are using something called the Shadow DOM to encapsulate their functionality. Understanding precisely how this works doesn’t necessarily matter for testing, but this article from Ire Aderinokun explains exactly what the Shadow DOM is and how it works.
change {
target: input#native-input,
isTrusted: true,
srcElement: input#native-input,
currentTarget: input#native-input,
eventPhase: 2,
bubbles: true,
cancelable: false,
returnValue: true,
defaultPrevented: false,
composed: false,
...
}
Object {
_reactName: "onChange",
_targetInst: null,
type: "change",
nativeEvent: input,
target: input,
currentTarget: input,
eventPhase: 3,
bubbles: true,
cancelable: false,
timeStamp: 94950,
...
}
Object {
originalEvent: change,
type: "change",
isDefaultPrevented: ha(),
timeStamp: 3236,
jQuery22206821608458388928: true,
which: undefined,
view: undefined,
target: input#jquery-input,
shiftKey: undefined,
relatedTarget: undefined,
...
}
I put together a very basic CodePen example to explore more what each event type will output to the console.
What’s the issue with testing?
Problems arise when using testing frameworks, like Cypress and Cucumber, that rely on using jQuery to try to call DOM events. They do an okay job with older inputs using click
events, but oftentimes choke on newer inputs with slightly different browser implementations.
The most common error I have personally run across is a test runner will use jQuery to invoke a change, the native DOM element will correctly update, but the event
will not be properly caught by the frontend framework. This shows up visually as an input having changed, but none of the other updates it should trigger elsewhere will be reflected.
How?
The most reliable way I have found to work around this issue is to use getOwnPropertyDescriptor to fish the .set()
method from a browser’s HTMLInputElement
object. That native method can then be called directly and attached to a DOM input element using the .dispatchEvent()
method.
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set
const changeInputValue = inputToChange => newValue => {
nativeInputValueSetter.call(inputToChange[0], newValue)
inputToChange[0].dispatchEvent(new Event('change', {
newValue,
bubbles: true
}))
}
This function uses Cypress’s jQuery interface to create an event, but still makes sure the full event is fired from the browser in a way that it can be caught by the framework’s event listening system.
It is important to set bubbles: true
in the dispatchEvent
configuration object so that the event will bubble
up until the framework can catch it. This is especially true of any framework using synthetic events, like React, as they sometimes use a single event listener and delegate responses to the appropriate DOM nodes.
To make this function more reusable, it can be added as a custom command within the cypress/support/commands.js
file.
Cypress.Commands.add('inputChange', (input, value) => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set
const changeInputValue = inputToChange => newValue => {
nativeInputValueSetter.call(inputToChange[0], newValue)
inputToChange[0].dispatchEvent(new Event('change', {
newValue,
bubbles: true
}))
}
return cy.get(input).then(input => {
changeInputValue(input)(value)
})
})
This command can now be called anywhere the cy
global object is available.
cy.get('#range-input').then(input => cy.inputChange(input, '15'))
This post is specifically about Cypress, which is the testing framework that this site currently uses. I’ve encountered and fixed this exact problem in Cucumber, the testing framework I use for Ruby code at work. The commonality between these two frameworks is they both use jQuery to orchestrate DOM events. There are a lot of testing frameworks out there, if the one you are using is also dependent on jQuery, this post might be helpful to you.